[fix] VerNum
This commit is contained in:
parent
68cedff1d9
commit
0827882bb3
30 changed files with 1651 additions and 240 deletions
|
|
@ -26,6 +26,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Set up repository
|
- name: Set up repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
env:
|
env:
|
||||||
|
|
@ -41,16 +43,6 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest"
|
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
|
- name: Read release metadata
|
||||||
id: version
|
id: version
|
||||||
env:
|
env:
|
||||||
|
|
@ -58,7 +50,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
VERSION="$(nix eval --raw .#iota-daemon.version)"
|
VERSION="$(nix eval --raw .#default.version)"
|
||||||
SHORT_SHA="$(git rev-parse --short=7 HEAD)"
|
SHORT_SHA="$(git rev-parse --short=7 HEAD)"
|
||||||
|
|
||||||
case "$RELEASE_TYPE" in
|
case "$RELEASE_TYPE" in
|
||||||
|
|
@ -80,11 +72,51 @@ jobs:
|
||||||
echo "tag=$TAG" >> "$FORGEJO_OUTPUT"
|
echo "tag=$TAG" >> "$FORGEJO_OUTPUT"
|
||||||
echo "title=$TAG" >> "$FORGEJO_OUTPUT"
|
echo "title=$TAG" >> "$FORGEJO_OUTPUT"
|
||||||
echo "prerelease=$PRERELEASE" >> "$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"
|
- name: Build and validate installer bundle
|
||||||
test -x "dist/iota-ui"
|
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:
|
env:
|
||||||
TOKEN: ${{ forgejo.token }}
|
TOKEN: ${{ forgejo.token }}
|
||||||
API: ${{ forgejo.api_url }}
|
API: ${{ forgejo.api_url }}
|
||||||
|
|
@ -93,6 +125,11 @@ jobs:
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
TITLE: ${{ steps.version.outputs.title }}
|
TITLE: ${{ steps.version.outputs.title }}
|
||||||
PRERELEASE: ${{ steps.version.outputs.prerelease }}
|
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 }}
|
DESCRIPTION: ${{ inputs.description }}
|
||||||
run: |
|
run: |
|
||||||
nix-shell -p curl jq --run '
|
nix-shell -p curl jq --run '
|
||||||
|
|
@ -120,11 +157,49 @@ jobs:
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \
|
upload_asset() {
|
||||||
-H "Authorization: token $TOKEN" \
|
TARGET_RELEASE_ID="$1"
|
||||||
-F "attachment=@dist/iota-daemon"
|
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" \
|
-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"
|
||||||
'
|
'
|
||||||
|
|
|
||||||
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -1435,6 +1435,16 @@ dependencies = [
|
||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -2281,8 +2291,12 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ed25519-dalek 2.2.0",
|
"ed25519-dalek 2.2.0",
|
||||||
|
"fs2",
|
||||||
"hex",
|
"hex",
|
||||||
|
"iota-ipc",
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
|
|
||||||
11
flake.nix
11
flake.nix
|
|
@ -46,7 +46,16 @@
|
||||||
pname = "iota";
|
pname = "iota";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoBuildFlags = ["-p" "iota" "-p" "iota-daemon"];
|
cargoBuildFlags = [
|
||||||
|
"-p"
|
||||||
|
"iota"
|
||||||
|
"-p"
|
||||||
|
"iota-daemon"
|
||||||
|
"-p"
|
||||||
|
"iota-updater"
|
||||||
|
"-p"
|
||||||
|
"iota-installer"
|
||||||
|
];
|
||||||
cargoLock = {
|
cargoLock = {
|
||||||
lockFile = ./Cargo.lock;
|
lockFile = ./Cargo.lock;
|
||||||
allowBuiltinFetchGit = true;
|
allowBuiltinFetchGit = true;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use crate::{
|
||||||
util::{buttons::draw_buttons, terms_focus::Focus},
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use iota_terms::{TermsType, get_link, get_terms};
|
use iota_terms::{LegalDocument, TermsType, get_link};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
|
@ -28,6 +28,7 @@ pub enum UserChoice {
|
||||||
|
|
||||||
pub struct TermsCheckerScreen {
|
pub struct TermsCheckerScreen {
|
||||||
sender: Option<oneshot::Sender<UserChoice>>,
|
sender: Option<oneshot::Sender<UserChoice>>,
|
||||||
|
documents: [LegalDocument; 3],
|
||||||
|
|
||||||
eula: bool,
|
eula: bool,
|
||||||
tos: bool,
|
tos: bool,
|
||||||
|
|
@ -37,9 +38,10 @@ pub struct TermsCheckerScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TermsCheckerScreen {
|
impl TermsCheckerScreen {
|
||||||
pub fn new(sender: Option<oneshot::Sender<UserChoice>>) -> Self {
|
pub fn new(sender: Option<oneshot::Sender<UserChoice>>, documents: [LegalDocument; 3]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sender,
|
sender,
|
||||||
|
documents,
|
||||||
eula: false,
|
eula: false,
|
||||||
tos: false,
|
tos: false,
|
||||||
pp: false,
|
pp: false,
|
||||||
|
|
@ -315,21 +317,25 @@ impl Screen for TermsCheckerScreen {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(terms_type) = terms_type {
|
if let Some(terms_type) = terms_type {
|
||||||
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> = Box::pin(
|
let document = self
|
||||||
async move {
|
.documents
|
||||||
if let Some(content) = get_terms(terms_type.clone()).await {
|
.iter()
|
||||||
|
.find(|document| document.kind() == terms_type)
|
||||||
|
.cloned();
|
||||||
|
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
|
||||||
|
Box::pin(async move {
|
||||||
|
if let Some(document) = document {
|
||||||
let screen: FileViewer =
|
let screen: FileViewer =
|
||||||
FileViewer::new(terms_type.to_string(), &content);
|
FileViewer::new(terms_type.to_string(), document.content());
|
||||||
Box::new(screen) as Box<dyn Screen>
|
Box::new(screen) as Box<dyn Screen>
|
||||||
} else {
|
} else {
|
||||||
let screen: FileViewer = FileViewer::new(
|
let screen: FileViewer = FileViewer::new(
|
||||||
"Error".to_string(),
|
"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<dyn Screen>
|
Box::new(screen) as Box<dyn Screen>
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
InteractionResult::OpenFutureScreen { screen: fut }
|
InteractionResult::OpenFutureScreen { screen: fut }
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Unhandled
|
InteractionResult::Unhandled
|
||||||
|
|
|
||||||
|
|
@ -827,17 +827,10 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
if !app_identifier.is_empty() {
|
if !app_identifier.is_empty() {
|
||||||
let user = match iota_storage::users::user_manager::get_user(sender_id) {
|
if iota_storage::users::user_manager::revoke_trusted_app(sender_id, &app_identifier)
|
||||||
Ok(user) => user,
|
.is_err()
|
||||||
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
{
|
||||||
};
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -950,6 +943,25 @@ fn account_state_error(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
error_response(cv, CommunicationType::ErrorInvalidData)
|
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.
|
/// 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 {
|
pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
let user_id = match required_sender_id(cv) {
|
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::Forever => None,
|
||||||
message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms),
|
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_request_id(cv)
|
||||||
.with_receiver(sender_wire_id(user_id))
|
.with_receiver(sender_wire_id(user_id))
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
|
|
@ -1021,15 +1033,8 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal
|
||||||
.map(|id| DataValue::SignedNumber(id.into()))
|
.map(|id| DataValue::SignedNumber(id.into()))
|
||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.add_typed_default(
|
let mut response = add_receipt_policy_data(response, &receipt_policy)
|
||||||
DataType::SendReadReceipts,
|
|
||||||
DataValue::Bool(receipt_policy.send_read_receipts),
|
|
||||||
)
|
|
||||||
.add_typed_default(
|
|
||||||
DataType::SendReceivedReceipts,
|
|
||||||
DataValue::Bool(receipt_policy.send_received_receipts),
|
|
||||||
)
|
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::MessageHistoryMode,
|
DataType::MessageHistoryMode,
|
||||||
DataValue::Str(match message_storage_policy.history_mode {
|
DataValue::Str(match message_storage_policy.history_mode {
|
||||||
|
|
@ -1060,6 +1065,45 @@ pub fn handle_account_state_applied(cv: &CommunicationValue) -> CommunicationVal
|
||||||
success_response(cv)
|
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 {
|
pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
let my_id = match cv.require_sender() {
|
let my_id = match cv.require_sender() {
|
||||||
Ok(my_id) => my_id,
|
Ok(my_id) => my_id,
|
||||||
|
|
@ -2222,21 +2266,10 @@ fn receipt_response(
|
||||||
ty: CommunicationType,
|
ty: CommunicationType,
|
||||||
policy: receipt_policy::ReceiptPolicy,
|
policy: receipt_policy::ReceiptPolicy,
|
||||||
) -> CommunicationValue {
|
) -> CommunicationValue {
|
||||||
CommunicationValue::new(ty)
|
let response = CommunicationValue::new(ty)
|
||||||
.with_request_id(cv)
|
.with_request_id(cv)
|
||||||
.with_receiver(sender_wire_id(policy.user_id))
|
.with_receiver(sender_wire_id(policy.user_id));
|
||||||
.add_typed_default(
|
add_receipt_policy_data(response, &policy)
|
||||||
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()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue {
|
pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
let Ok(user_id) = authenticated_user(cv) else {
|
let Ok(user_id) = authenticated_user(cv) else {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice};
|
use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice};
|
||||||
use iota_cli::ui::UI;
|
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 iota_util::file_util::{load_file, save_file};
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
pub async fn check(ui: Arc<UI>) -> 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<UI>) -> Result<ConsentCheck, String> {
|
||||||
let mut state = ConsentState::load_state();
|
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
|
* The raw legal endpoint exposes only the current document. Restore this
|
||||||
* flow when it provides future versions that users can accept early.
|
* flow when it provides future versions that users can accept early.
|
||||||
|
|
@ -20,7 +26,11 @@ pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
|
||||||
state = state.sanitize();
|
state = state.sanitize();
|
||||||
state.save_state();
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -38,38 +48,54 @@ pub fn non_interactive_consent() -> NonInteractiveConsent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> {
|
async fn ensure_initial_consent(
|
||||||
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
|
ui: Arc<UI>,
|
||||||
return Ok(());
|
state: &mut ConsentState,
|
||||||
}
|
) -> Result<[LegalDocument; 3], String> {
|
||||||
|
|
||||||
let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| {
|
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()
|
"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();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx))))
|
ui.set_screen(Box::new(TermsCheckerScreen::new(
|
||||||
.await;
|
Some(tx),
|
||||||
|
documents.clone(),
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
|
||||||
let result = rx.await.unwrap_or(UserChoice::Deny);
|
let result = rx.await.unwrap_or(UserChoice::Deny);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
UserChoice::AcceptEULA | UserChoice::AcceptAll => {
|
UserChoice::AcceptEULA | UserChoice::AcceptAll => {
|
||||||
state.accepted_eula = true;
|
state.accepted_eula = true;
|
||||||
state.eula = Some(current_eula);
|
state.eula = Some(current_eula_doc);
|
||||||
|
|
||||||
if matches!(result, UserChoice::AcceptAll) {
|
if matches!(result, UserChoice::AcceptAll) {
|
||||||
state.accepted_tos = true;
|
state.accepted_tos = true;
|
||||||
state.accepted_pp = true;
|
state.accepted_pp = true;
|
||||||
state.tos = Some(current_tos);
|
state.tos = Some(current_tos_doc);
|
||||||
state.privacy = Some(current_privacy);
|
state.privacy = Some(current_privacy_doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = &state.save_state();
|
let _ = &state.save_state();
|
||||||
Ok(())
|
Ok(documents)
|
||||||
}
|
}
|
||||||
UserChoice::Deny => Ok(()),
|
UserChoice::Deny => Ok(documents),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|
@ -245,6 +271,22 @@ pub struct ConsentState {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn sanitize(mut self) -> Self {
|
||||||
let current_secs = SystemTime::now()
|
let current_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|
@ -488,3 +530,60 @@ impl ConsentState {
|
||||||
state
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ async fn main() {
|
||||||
let session = start_tui(ipc).expect("interactive terminal initialization failed");
|
let session = start_tui(ipc).expect("interactive terminal initialization failed");
|
||||||
let ui = session.ui();
|
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,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
*state.shutdown.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
|
|
@ -53,7 +53,7 @@ async fn main() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !eula {
|
if !consent.accepted_eula {
|
||||||
*state.shutdown.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
loop {
|
loop {
|
||||||
if state.active_tasks.is_empty() {
|
if state.active_tasks.is_empty() {
|
||||||
|
|
@ -65,7 +65,7 @@ async fn main() {
|
||||||
println!("You can find this at 'agreements'!");
|
println!("You can find this at 'agreements'!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !tos_pp {
|
if !consent.accepted_services {
|
||||||
*state.shutdown.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
loop {
|
loop {
|
||||||
if state.active_tasks.is_empty() {
|
if state.active_tasks.is_empty() {
|
||||||
|
|
@ -102,6 +102,10 @@ async fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// USER MANAGEMENT
|
// 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() {
|
if let Err(_) = user_manager::load_users_sync() {
|
||||||
log_t!("user_load_failed");
|
log_t!("user_load_failed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,9 @@ async fn main() -> ExitCode {
|
||||||
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
|
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
|
||||||
|
|
||||||
runtime.set_startup_phase(StartupPhase::LoadingUsers);
|
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)
|
Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("user storage task failed: {error}"))
|
.map_err(|error| format!("user storage task failed: {error}"))
|
||||||
|
|
|
||||||
10
iota-installer/bundle-files.txt
Normal file
10
iota-installer/bundle-files.txt
Normal file
|
|
@ -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
|
||||||
12
iota-installer/src/bin/iota-bundle.rs
Normal file
12
iota-installer/src/bin/iota-bundle.rs
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -3,17 +3,16 @@ use std::{fs, io, path::Path, process::Command};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use zip::ZipArchive;
|
use zip::ZipArchive;
|
||||||
|
|
||||||
const REQUIRED: &[&str] = &[
|
const REQUIRED: &str = include_str!("../bundle-files.txt");
|
||||||
"bin/iota",
|
const DAEMON_EXECUTABLE_PLACEHOLDER: &str = "@IOTA_SYSTEM_DAEMON_EXECUTABLE@";
|
||||||
"bin/iota-daemon",
|
|
||||||
"bin/iota-updater",
|
pub fn validate_linux_bundle(bundle: &Path) -> Result<()> {
|
||||||
"systemd/iota-daemon.service",
|
let staging = tempdir().context("create bundle validation directory")?;
|
||||||
"systemd/iota-daemon.socket",
|
extract_linux_bundle(bundle, staging.path())?;
|
||||||
"systemd/sysusers.d/iota.conf",
|
product_version(staging.path())?;
|
||||||
"systemd/iota-update.service",
|
validate_update_environment(staging.path())?;
|
||||||
"systemd/iota-update.timer",
|
Ok(())
|
||||||
"manifest.json",
|
}
|
||||||
];
|
|
||||||
|
|
||||||
pub fn install_linux_bundle(bundle: &Path) -> Result<()> {
|
pub fn install_linux_bundle(bundle: &Path) -> Result<()> {
|
||||||
install_linux_bundle_with_operator(bundle, None)
|
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");
|
bail!("Linux systemd bundles are not supported on this platform");
|
||||||
}
|
}
|
||||||
let staging = tempdir().context("create installer staging directory")?;
|
let staging = tempdir().context("create installer staging directory")?;
|
||||||
let file = fs::File::open(bundle).context("open release bundle")?;
|
extract_linux_bundle(bundle, staging.path())?;
|
||||||
let mut archive = ZipArchive::new(file).context("read release bundle")?;
|
let product_version = product_version(staging.path())?;
|
||||||
for name in REQUIRED {
|
validate_update_environment(staging.path())?;
|
||||||
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)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
render_daemon_service(&staging.path().join("systemd/iota-daemon.service"))?;
|
||||||
|
|
||||||
|
let version_dir = format!(
|
||||||
|
"{}/versions/{product_version}",
|
||||||
|
iota_paths::install_root().display()
|
||||||
|
);
|
||||||
install(
|
install(
|
||||||
&staging.path().join("bin/iota"),
|
&staging.path().join("bin/iota"),
|
||||||
&format!(
|
&format!("{version_dir}/bin/iota"),
|
||||||
"{}/versions/{}/bin/iota",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
),
|
|
||||||
"0755",
|
"0755",
|
||||||
)?;
|
)?;
|
||||||
install(
|
install(
|
||||||
&staging.path().join("bin/iota-daemon"),
|
&staging.path().join("bin/iota-daemon"),
|
||||||
&format!(
|
&format!("{version_dir}/bin/iota-daemon"),
|
||||||
"{}/versions/{}/bin/iota-daemon",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
),
|
|
||||||
"0755",
|
"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() {
|
if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() {
|
||||||
bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon");
|
bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon");
|
||||||
}
|
}
|
||||||
install(
|
install(
|
||||||
&staging.path().join("bin/iota-updater"),
|
&staging.path().join("bin/iota-updater"),
|
||||||
&format!(
|
&format!("{version_dir}/bin/iota-updater"),
|
||||||
"{}/versions/{}/bin/iota-updater",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
),
|
|
||||||
"0755",
|
"0755",
|
||||||
)?;
|
)?;
|
||||||
|
install(
|
||||||
|
&staging.path().join("manifest.json"),
|
||||||
|
&format!("{version_dir}/manifest.json"),
|
||||||
|
"0644",
|
||||||
|
)?;
|
||||||
for unit in [
|
for unit in [
|
||||||
"iota-daemon.service",
|
"iota-daemon.service",
|
||||||
"iota-daemon.socket",
|
"iota-daemon.socket",
|
||||||
|
|
@ -94,6 +77,11 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
|
||||||
"/etc/sysusers.d/iota.conf",
|
"/etc/sysusers.d/iota.conf",
|
||||||
"0644",
|
"0644",
|
||||||
)?;
|
)?;
|
||||||
|
install(
|
||||||
|
&staging.path().join("systemd/update.env"),
|
||||||
|
"/etc/iota/update.env",
|
||||||
|
"0644",
|
||||||
|
)?;
|
||||||
run(
|
run(
|
||||||
"ln",
|
"ln",
|
||||||
&[
|
&[
|
||||||
|
|
@ -118,7 +106,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
|
||||||
"{}/current/bin/iota-daemon",
|
"{}/current/bin/iota-daemon",
|
||||||
iota_paths::install_root().display()
|
iota_paths::install_root().display()
|
||||||
),
|
),
|
||||||
"/usr/local/libexec/iota/iota-daemon",
|
iota_paths::SYSTEM_DAEMON_EXECUTABLE,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
run("systemd-sysusers", &[])?;
|
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", &["daemon-reload"])?;
|
||||||
run("systemctl", &["enable", "--now", "iota-daemon.socket"])?;
|
run("systemctl", &["enable", "--now", "iota-daemon.socket"])?;
|
||||||
|
run("systemctl", &["enable", "--now", "iota-update.timer"])?;
|
||||||
run("systemctl", &["is-active", "iota-daemon.socket"])?;
|
run("systemctl", &["is-active", "iota-daemon.socket"])?;
|
||||||
run("systemctl", &["is-enabled", "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);
|
let socket = iota_paths::socket_path(iota_paths::Scope::System);
|
||||||
if !socket.exists() {
|
if !socket.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
|
|
@ -150,17 +140,107 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn product_version(staging: &Path) -> String {
|
fn extract_linux_bundle(bundle: &Path, staging: &Path) -> Result<()> {
|
||||||
fs::read_to_string(staging.join("manifest.json"))
|
let file = fs::File::open(bundle).context("open release bundle")?;
|
||||||
.ok()
|
let mut archive = ZipArchive::new(file).context("read release bundle")?;
|
||||||
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
for name in REQUIRED.lines().filter(|name| !name.is_empty()) {
|
||||||
.and_then(|value| {
|
let mut entry = archive
|
||||||
value
|
.by_name(name)
|
||||||
.get("product_version")
|
.with_context(|| format!("bundle is missing {name}"))?;
|
||||||
.and_then(|v| v.as_str())
|
let output = staging.join(name);
|
||||||
.map(str::to_owned)
|
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<String> {
|
||||||
|
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<()> {
|
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")
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ use std::env;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::{Path, PathBuf};
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum Scope {
|
pub enum Scope {
|
||||||
User,
|
User,
|
||||||
|
|
|
||||||
|
|
@ -46,37 +46,7 @@ pub fn try_add_user_with_credential_origin(
|
||||||
credential_origin: CredentialOrigin,
|
credential_origin: CredentialOrigin,
|
||||||
) -> Result<(), crate::storage_error::StorageError> {
|
) -> Result<(), crate::storage_error::StorageError> {
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
tx.execute(
|
persist_user_profile(tx, &user)?;
|
||||||
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],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at)
|
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)
|
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> {
|
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(
|
pub fn get_user_by_username(
|
||||||
|
|
@ -659,9 +668,12 @@ pub fn load_users_sync() -> std::io::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod purge_tests {
|
mod tests {
|
||||||
use super::{PurgeStage, remaining_purge_stages, run_purge_stages};
|
use super::{
|
||||||
|
PurgeStage, UserProfile, persist_user_profile, remaining_purge_stages, run_purge_stages,
|
||||||
|
};
|
||||||
use crate::users::pending_operations::PendingUserOperationPhase;
|
use crate::users::pending_operations::PendingUserOperationPhase;
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prepared_purge_runs_every_stage_before_completion() {
|
fn prepared_purge_runs_every_stage_before_completion() {
|
||||||
|
|
@ -725,6 +737,82 @@ mod purge_tests {
|
||||||
assert!(!data_empty);
|
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) {
|
pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::OnceCell;
|
||||||
use r2d2::ManageConnection;
|
use r2d2::ManageConnection;
|
||||||
use rusqlite::{Connection, Transaction};
|
use rusqlite::{Connection, Transaction};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -10,15 +10,16 @@ use crate::storage_error::StorageError;
|
||||||
const DB_NAME: &str = "messages";
|
const DB_NAME: &str = "messages";
|
||||||
|
|
||||||
/// A simple r2d2 manager for rusqlite connections.
|
/// A simple r2d2 manager for rusqlite connections.
|
||||||
pub struct SqliteManager;
|
pub struct SqliteManager {
|
||||||
|
database_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
impl ManageConnection for SqliteManager {
|
impl ManageConnection for SqliteManager {
|
||||||
type Connection = Connection;
|
type Connection = Connection;
|
||||||
type Error = rusqlite::Error;
|
type Error = rusqlite::Error;
|
||||||
|
|
||||||
fn connect(&self) -> Result<Connection, rusqlite::Error> {
|
fn connect(&self) -> Result<Connection, rusqlite::Error> {
|
||||||
let path = db_file_path(DB_NAME);
|
let conn = Connection::open(&self.database_path)?;
|
||||||
let conn = Connection::open(path)?;
|
|
||||||
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?;
|
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?;
|
||||||
conn.busy_timeout(Duration::from_millis(250))?;
|
conn.busy_timeout(Duration::from_millis(250))?;
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
|
|
@ -33,18 +34,43 @@ impl ManageConnection for SqliteManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static POOL: Lazy<Arc<r2d2::Pool<SqliteManager>>> = Lazy::new(|| {
|
static POOL: OnceCell<Arc<r2d2::Pool<SqliteManager>>> = OnceCell::new();
|
||||||
let manager = SqliteManager;
|
|
||||||
|
fn create_pool(database_path: PathBuf) -> Result<Arc<r2d2::Pool<SqliteManager>>, StorageError> {
|
||||||
|
create_pool_with_timeout(database_path, Duration::from_secs(30))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_pool_with_timeout(
|
||||||
|
database_path: PathBuf,
|
||||||
|
connection_timeout: Duration,
|
||||||
|
) -> Result<Arc<r2d2::Pool<SqliteManager>>, StorageError> {
|
||||||
|
let manager = SqliteManager { database_path };
|
||||||
let pool = r2d2::Pool::builder()
|
let pool = r2d2::Pool::builder()
|
||||||
.max_size(8)
|
.max_size(8)
|
||||||
|
.connection_timeout(connection_timeout)
|
||||||
.build(manager)
|
.build(manager)
|
||||||
.expect("Failed to create database connection pool");
|
.map_err(|error| StorageError::Pool(error.to_string()))?;
|
||||||
run_migrations(&pool).expect("Failed to run database migrations");
|
run_migrations(&pool)?;
|
||||||
Arc::new(pool)
|
Ok(Arc::new(pool))
|
||||||
});
|
}
|
||||||
|
|
||||||
pub fn pool() -> Arc<r2d2::Pool<SqliteManager>> {
|
/// Opens the SQLite pool and applies all schema migrations.
|
||||||
POOL.clone()
|
///
|
||||||
|
/// 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<Arc<r2d2::Pool<SqliteManager>>, StorageError> {
|
||||||
|
POOL.get()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| StorageError::Other("database has not been initialized".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_db<T, F>(f: F) -> Result<T, StorageError>
|
pub fn with_db<T, F>(f: F) -> Result<T, StorageError>
|
||||||
|
|
@ -52,7 +78,8 @@ where
|
||||||
F: FnOnce(&Connection) -> Result<T, StorageError>,
|
F: FnOnce(&Connection) -> Result<T, StorageError>,
|
||||||
{
|
{
|
||||||
blocking_region(|| {
|
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)
|
f(&conn)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +89,8 @@ where
|
||||||
F: FnOnce(&Transaction<'_>) -> Result<T, StorageError>,
|
F: FnOnce(&Transaction<'_>) -> Result<T, StorageError>,
|
||||||
{
|
{
|
||||||
blocking_region(|| {
|
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 tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||||
let value = f(&tx)?;
|
let value = f(&tx)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|
@ -962,6 +990,18 @@ pub fn create_general_messages_db() -> Result<Arc<std::sync::Mutex<Connection>>,
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> {
|
fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> {
|
||||||
let conn = Connection::open_in_memory()?;
|
let conn = Connection::open_in_memory()?;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,52 @@
|
||||||
use crate::terms_getter::Type;
|
use crate::terms_getter::Type;
|
||||||
use iota_util::crypto_helper::hex_hash;
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct Doc {
|
pub struct Doc {
|
||||||
|
|
@ -58,7 +104,7 @@ impl Doc {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Doc;
|
use super::{Doc, LegalDocument};
|
||||||
use crate::terms_getter::Type;
|
use crate::terms_getter::Type;
|
||||||
use iota_util::crypto_helper::hex_hash;
|
use iota_util::crypto_helper::hex_hash;
|
||||||
|
|
||||||
|
|
@ -73,6 +119,18 @@ mod tests {
|
||||||
assert_eq!(document.get_hash(), hex_hash(&content));
|
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]
|
#[test]
|
||||||
fn matching_documents_ignore_the_fetch_timestamp() {
|
fn matching_documents_ignore_the_fetch_timestamp() {
|
||||||
let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123);
|
let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123);
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,4 @@ pub use terms_getter::get_terms;
|
||||||
|
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
|
|
||||||
pub use doc::Doc;
|
pub use doc::{Doc, LegalDocument};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::doc::Doc;
|
use crate::doc::LegalDocument;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -41,7 +41,7 @@ pub fn get_newest_link(terms_type: Type) -> String {
|
||||||
get_link(terms_type)
|
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 timestamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
||||||
let (eula, tos, privacy) = tokio::join!(
|
let (eula, tos, privacy) = tokio::join!(
|
||||||
get_terms(Type::EULA),
|
get_terms(Type::EULA),
|
||||||
|
|
@ -50,9 +50,9 @@ pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> {
|
||||||
);
|
);
|
||||||
|
|
||||||
Some((
|
Some((
|
||||||
Doc::from_raw(Type::EULA, eula?, timestamp),
|
LegalDocument::from_raw(Type::EULA, eula?, timestamp),
|
||||||
Doc::from_raw(Type::TOS, tos?, timestamp),
|
LegalDocument::from_raw(Type::TOS, tos?, timestamp),
|
||||||
Doc::from_raw(Type::PP, privacy?, timestamp),
|
LegalDocument::from_raw(Type::PP, privacy?, timestamp),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iota-paths = { path = "../iota-paths" }
|
iota-paths = { path = "../iota-paths" }
|
||||||
|
iota-ipc = { path = "../iota-ipc" }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
hex = "*"
|
hex = "*"
|
||||||
serde = "1.0.228"
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
ed25519-dalek = "2.2.0"
|
ed25519-dalek = "2.2.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
reqwest = "0.13.2"
|
||||||
|
fs2 = "0.4.3"
|
||||||
|
rusqlite = "0.40.0"
|
||||||
|
|
|
||||||
3
iota-updater/artifacts.tsv
Normal file
3
iota-updater/artifacts.tsv
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
cli bin/iota
|
||||||
|
daemon bin/iota-daemon
|
||||||
|
updater bin/iota-updater
|
||||||
|
50
iota-updater/src/bin/iota-release.rs
Normal file
50
iota-updater/src/bin/iota-release.rs
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,520 @@
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod transaction;
|
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;
|
const MANIFEST_ENV: &str = "IOTA_UPDATE_MANIFEST";
|
||||||
/// this function only checks and never replaces the invoking executable.
|
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 `<manifest>.sig`.
|
||||||
pub async fn check_update() -> Result<bool> {
|
pub async fn check_update() -> Result<bool> {
|
||||||
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<bool> {
|
||||||
|
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);
|
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<Artifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configured_release() -> Result<Option<ConfiguredRelease>> {
|
||||||
|
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<ConfiguredRelease> {
|
||||||
|
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<Vec<Artifact>> {
|
||||||
|
let requirements = required_host_artifacts()?;
|
||||||
|
let artifacts: Vec<Artifact> = 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<Vec<(&'static str, &'static str)>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<tempfile::NamedTempFile> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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<ReleaseManifest> {
|
||||||
|
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<Option<u64>> {
|
||||||
|
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<Artifact>) -> 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<Artifact> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,19 @@ use anyhow::Result;
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
|
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"check" => println!("update check is manifest-driven"),
|
"check" => println!("{}", iota_updater::check_update().await?),
|
||||||
"status" => println!("updater ready"),
|
"status" => println!("updater ready"),
|
||||||
"apply" | "rollback" => {
|
"apply" => println!("{}", iota_updater::apply_update().await?),
|
||||||
return Err(anyhow::anyhow!(
|
"rollback" => {
|
||||||
"explicit signed transaction input is required"
|
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!(
|
return Err(anyhow::anyhow!(
|
||||||
"usage: iota-updater check|status|apply|rollback"
|
"usage: iota-updater check|status|apply|rollback VERSION"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use crate::manifest::{Artifact, verify_artifact};
|
use crate::manifest::{Artifact, verify_artifact};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use fs2::FileExt;
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -27,31 +28,58 @@ impl UpdateTransaction {
|
||||||
lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?,
|
lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn acquire(&self) -> Result<fs::File> {
|
pub fn acquire(&self) -> Result<UpdateLock> {
|
||||||
if let Some(parent) = self.lock_file.parent() {
|
if let Some(parent) = self.lock_file.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
let file = fs::OpenOptions::new()
|
let file = fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create_new(true)
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
.open(&self.lock_file)
|
.open(&self.lock_file)
|
||||||
|
.with_context(|| format!("open update lock {}", self.lock_file.display()))?;
|
||||||
|
file.try_lock_exclusive()
|
||||||
.context("update already in progress")?;
|
.context("update already in progress")?;
|
||||||
Ok(file)
|
Ok(UpdateLock { _file: file })
|
||||||
}
|
}
|
||||||
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> {
|
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> {
|
||||||
|
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)?;
|
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() {
|
if let Some(parent) = target.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
fs::copy(source, &target)?;
|
fs::copy(source, &target)?;
|
||||||
verify_artifact(&target, artifact)?;
|
verify_artifact(&target, artifact)?;
|
||||||
|
set_executable_if_binary(&target, artifact)?;
|
||||||
Ok(target)
|
Ok(target)
|
||||||
}
|
}
|
||||||
pub fn activate(&self, version: &str) -> Result<()> {
|
pub fn activate(&self, version: &str) -> Result<()> {
|
||||||
let version_dir = self.root.join("versions").join(version);
|
let version_dir = self.root.join("versions").join(version);
|
||||||
fs::create_dir_all(version_dir.parent().unwrap())?;
|
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 current_tmp = self.root.join("current.new");
|
||||||
let _ = fs::remove_file(¤t_tmp);
|
let _ = fs::remove_file(¤t_tmp);
|
||||||
std::os::unix::fs::symlink(&version_dir, ¤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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
fn lock_is_exclusive_and_activation_switches_current() {
|
fn lock_is_exclusive_reusable_and_activation_switches_current() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let tx = UpdateTransaction::new(dir.path());
|
let tx = UpdateTransaction::new(dir.path());
|
||||||
let lock = tx.acquire().unwrap();
|
let lock = tx.acquire().unwrap();
|
||||||
assert!(tx.acquire().is_err());
|
assert!(tx.acquire().is_err());
|
||||||
drop(lock);
|
drop(lock);
|
||||||
|
assert!(tx.acquire().is_ok());
|
||||||
|
assert!(tx.lock_file.exists());
|
||||||
std::fs::create_dir_all(&tx.staging).unwrap();
|
std::fs::create_dir_all(&tx.staging).unwrap();
|
||||||
std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap();
|
std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap();
|
||||||
tx.activate("1.0.0").unwrap();
|
tx.activate("1.0.0").unwrap();
|
||||||
|
|
|
||||||
|
|
@ -286,10 +286,10 @@ async fn run_dashboard(
|
||||||
let result = async {
|
let result = async {
|
||||||
let consent = iota_core::consent_state::check(ui.clone()).await
|
let consent = iota_core::consent_state::check(ui.clone()).await
|
||||||
.map_err(StartupError::Consent)?;
|
.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()));
|
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! {
|
let initial = tokio::select! {
|
||||||
result = connect_available(&endpoints) => result,
|
result = connect_available(&endpoints) => result,
|
||||||
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
||||||
|
|
@ -340,15 +340,14 @@ async fn run_dashboard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_dashboard_consent() -> Result<(), StartupError> {
|
fn persist_dashboard_consent(
|
||||||
let docs = iota_terms::get_current_docs().await.ok_or_else(|| {
|
documents: &[iota_terms::LegalDocument; 3],
|
||||||
StartupError::Consent("Could not verify the current agreements after acceptance.".into())
|
) -> Result<(), StartupError> {
|
||||||
})?;
|
|
||||||
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
||||||
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?;
|
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?;
|
||||||
let mut record = iota_terms::consent::load(&paths.state_dir);
|
let mut record = iota_terms::consent::load(&paths.state_dir);
|
||||||
for document in [&docs.0, &docs.1, &docs.2] {
|
for document in documents {
|
||||||
record.accept(document);
|
record.accept(&document.metadata());
|
||||||
}
|
}
|
||||||
iota_terms::consent::save(&paths.state_dir, &record)
|
iota_terms::consent::save(&paths.state_dir, &record)
|
||||||
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))
|
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::startup_error::StartupError;
|
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};
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
pub enum TermsCommand {
|
pub enum TermsCommand {
|
||||||
|
|
@ -71,25 +71,19 @@ async fn accept(system: bool) -> Result<(), StartupError> {
|
||||||
})?;
|
})?;
|
||||||
let mut record = consent::load(&state_dir(system)?);
|
let mut record = consent::load(&state_dir(system)?);
|
||||||
for document in [&documents.0, &documents.1, &documents.2] {
|
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!(
|
println!(
|
||||||
"\n===== {} =====\nVersion: {}\nDocument hash: {}\n",
|
"\n===== {} =====\nVersion: {}\nDocument hash: {}\n",
|
||||||
document.doc_type.to_string(),
|
document.kind().to_string(),
|
||||||
document.get_version(),
|
document.version(),
|
||||||
document.get_hash()
|
document.hash()
|
||||||
);
|
);
|
||||||
print!("{text}\n");
|
print!("{}\n", document.content());
|
||||||
if !confirm(document)? {
|
if !confirm(document)? {
|
||||||
return Err(StartupError::Consent(
|
return Err(StartupError::Consent(
|
||||||
"No terms were accepted. Iota remains inactive.".into(),
|
"No terms were accepted. Iota remains inactive.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
record.accept(document);
|
record.accept(&document.metadata());
|
||||||
}
|
}
|
||||||
consent::save(&state_dir(system)?, &record)
|
consent::save(&state_dir(system)?, &record)
|
||||||
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?;
|
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?;
|
||||||
|
|
@ -97,13 +91,12 @@ async fn accept(system: bool) -> Result<(), StartupError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm(document: &Doc) -> Result<bool, StartupError> {
|
fn confirm(document: &LegalDocument) -> Result<bool, StartupError> {
|
||||||
let hash = document.get_hash();
|
let prefix = document.hash().get(..10).unwrap_or(document.hash());
|
||||||
let prefix = hash.get(..10).unwrap_or(&hash);
|
|
||||||
let expected = format!(
|
let expected = format!(
|
||||||
"ACCEPT {} {} {}",
|
"ACCEPT {} {} {}",
|
||||||
document.doc_type.to_str().to_ascii_uppercase(),
|
document.kind().to_str().to_ascii_uppercase(),
|
||||||
document.get_version(),
|
document.version(),
|
||||||
prefix
|
prefix
|
||||||
);
|
);
|
||||||
print!("To accept this exact document, type:\n{expected}\n> ");
|
print!("To accept this exact document, type:\n{expected}\n> ");
|
||||||
|
|
|
||||||
58
scripts/build-release-bundle.sh
Normal file
58
scripts/build-release-bundle.sh
Normal file
|
|
@ -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[@]}"
|
||||||
|
)
|
||||||
55
scripts/build-update-manifest.sh
Normal file
55
scripts/build-update-manifest.sh
Normal file
|
|
@ -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"
|
||||||
|
|
@ -6,9 +6,10 @@ Requires=iota-daemon.socket
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/lib/iota/iota-daemon
|
ExecStart=@IOTA_SYSTEM_DAEMON_EXECUTABLE@
|
||||||
User=iota
|
User=iota
|
||||||
Group=iota
|
Group=iota
|
||||||
|
EnvironmentFile=/etc/iota/update.env
|
||||||
StateDirectory=iota
|
StateDirectory=iota
|
||||||
StateDirectoryMode=0750
|
StateDirectoryMode=0750
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
|
||||||
9
systemd/iota-update.service
Normal file
9
systemd/iota-update.service
Normal file
|
|
@ -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
|
||||||
11
systemd/iota-update.timer
Normal file
11
systemd/iota-update.timer
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue