[fix] VerNum

This commit is contained in:
Alex Emmet 2026-09-10 17:53:09 +02:00
commit 0827882bb3
No known key found for this signature in database
30 changed files with 1651 additions and 240 deletions

View 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)
}

View file

@ -1,13 +1,520 @@
pub mod manifest;
pub mod transaction;
use anyhow::Result;
use anyhow::{Context, Result, bail};
use manifest::{Artifact, ReleaseManifest, verify_signature};
use std::{fs, path::Path};
use transaction::UpdateTransaction;
/// Compatibility entry point used by the UI. Updates are now manifest-driven;
/// this function only checks and never replaces the invoking executable.
const MANIFEST_ENV: &str = "IOTA_UPDATE_MANIFEST";
const SIGNATURE_ENV: &str = "IOTA_UPDATE_SIGNATURE";
const PUBLIC_KEY_ENV: &str = "IOTA_UPDATE_PUBLIC_KEY";
const REQUIRED_HOST_ARTIFACTS: &str = include_str!("../artifacts.tsv");
/// Reads the configured signed release manifest and reports whether it differs
/// from the release currently selected by the installation's `current` link.
/// A configured manifest always requires `IOTA_UPDATE_PUBLIC_KEY` (32-byte hex)
/// and a hex signature, supplied by `IOTA_UPDATE_SIGNATURE` or `<manifest>.sig`.
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);
}
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"));
}
}

View file

@ -4,16 +4,19 @@ use anyhow::Result;
async fn main() -> Result<()> {
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
match command.as_str() {
"check" => println!("update check is manifest-driven"),
"check" => println!("{}", iota_updater::check_update().await?),
"status" => println!("updater ready"),
"apply" | "rollback" => {
return Err(anyhow::anyhow!(
"explicit signed transaction input is required"
));
"apply" => println!("{}", iota_updater::apply_update().await?),
"rollback" => {
let version = std::env::args()
.nth(2)
.ok_or_else(|| anyhow::anyhow!("usage: iota-updater rollback VERSION"))?;
iota_updater::rollback(&version)?;
println!("rolled back to {version}");
}
_ => {
return Err(anyhow::anyhow!(
"usage: iota-updater check|status|apply|rollback"
"usage: iota-updater check|status|apply|rollback VERSION"
));
}
}

View file

@ -1,8 +1,9 @@
use crate::manifest::{Artifact, verify_artifact};
use anyhow::{Context, Result};
use fs2::FileExt;
use std::{
fs,
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
};
#[derive(Clone, Debug)]
@ -27,31 +28,58 @@ impl UpdateTransaction {
lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?,
})
}
pub fn acquire(&self) -> Result<fs::File> {
pub fn acquire(&self) -> Result<UpdateLock> {
if let Some(parent) = self.lock_file.parent() {
fs::create_dir_all(parent)?;
}
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.create(true)
.truncate(false)
.open(&self.lock_file)
.with_context(|| format!("open update lock {}", self.lock_file.display()))?;
file.try_lock_exclusive()
.context("update already in progress")?;
Ok(file)
Ok(UpdateLock { _file: file })
}
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<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)?;
let target = self.staging.join(&artifact.path);
let target = self.staging.join(artifact_path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, &target)?;
verify_artifact(&target, artifact)?;
set_executable_if_binary(&target, artifact)?;
Ok(target)
}
pub fn activate(&self, version: &str) -> Result<()> {
let version_dir = self.root.join("versions").join(version);
fs::create_dir_all(version_dir.parent().unwrap())?;
fs::rename(&self.staging, &version_dir).context("activate staged release")?;
if version_dir.exists() {
anyhow::bail!("release version already exists: {}", version_dir.display());
}
match fs::rename(&self.staging, &version_dir) {
Ok(()) => {}
Err(error) if error.raw_os_error() == Some(cross_device_link_error()) => {
copy_directory(&self.staging, &version_dir)?;
fs::remove_dir_all(&self.staging)
.context("remove copied update staging directory")?;
}
Err(error) => return Err(error).context("activate staged release"),
}
let current_tmp = self.root.join("current.new");
let _ = fs::remove_file(&current_tmp);
std::os::unix::fs::symlink(&version_dir, &current_tmp)?;
@ -68,16 +96,79 @@ impl UpdateTransaction {
}
}
#[cfg(unix)]
fn set_executable_if_binary(path: &Path, artifact: &Artifact) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if Path::new(&artifact.path)
.components()
.next()
.is_some_and(|component| component.as_os_str() == "bin")
{
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[cfg(not(unix))]
fn set_executable_if_binary(_path: &Path, _artifact: &Artifact) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn cross_device_link_error() -> i32 {
18
}
#[cfg(not(unix))]
fn cross_device_link_error() -> i32 {
-1
}
fn copy_directory(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| format!("create release directory {}", destination.display()))?;
for entry in fs::read_dir(source).with_context(|| format!("read {}", source.display()))? {
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_directory(&source_path, &destination_path)?;
} else {
fs::copy(&source_path, &destination_path).with_context(|| {
format!(
"copy staged artifact {} to {}",
source_path.display(),
destination_path.display()
)
})?;
fs::set_permissions(&destination_path, fs::metadata(&source_path)?.permissions())?;
}
}
Ok(())
}
#[derive(Debug)]
pub struct UpdateLock {
// Closing this file releases its advisory lock. The lock file stays in place
// so a process crash cannot leave a stale create-only lock behind.
_file: fs::File,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_is_exclusive_and_activation_switches_current() {
fn lock_is_exclusive_reusable_and_activation_switches_current() {
let dir = tempfile::tempdir().unwrap();
let tx = UpdateTransaction::new(dir.path());
let lock = tx.acquire().unwrap();
assert!(tx.acquire().is_err());
drop(lock);
assert!(tx.acquire().is_ok());
assert!(tx.lock_file.exists());
std::fs::create_dir_all(&tx.staging).unwrap();
std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap();
tx.activate("1.0.0").unwrap();