use super::github_release::{LatestRelease, ReleaseAsset};
use crate::{
platform::{config::UpdateConfig, https_client},
store::Db,
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use shared::{UpdateProgress, UpdateStage};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixStream,
};
use update_core::outcome::{OutcomeKind, UpdateOutcome, read_outcome};
const HELPER_SOCKET: &str = "/run/rustploy/update.sock";
const UPDATES_DIR: &str = "/var/lib/rustploy/updates";
const OUTCOME_FILE: &str = "/var/lib/rustploy-update/last.json";
const DOWNLOAD_HOSTS: &[&str] = &["github.com", "githubusercontent.com"];
const MAX_DEB_BYTES: u64 = 200 * 1024 * 1024;
const MAX_SIGNATURE_BYTES: u64 = 8 * 1024;
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
const HELPER_ACCEPT_TIMEOUT: Duration = Duration::from_secs(60);
const SNAPSHOTS_KEPT: usize = 3;
const STALE_IN_PROGRESS_SECS: u64 = 30 * 60;
#[derive(Clone)]
pub struct HelperPaths {
pub socket: PathBuf,
pub updates_dir: PathBuf,
pub outcome_file: PathBuf,
}
impl Default for HelperPaths {
fn default() -> Self {
Self {
socket: HELPER_SOCKET.into(),
updates_dir: UPDATES_DIR.into(),
outcome_file: OUTCOME_FILE.into(),
}
}
}
#[derive(Clone)]
struct ApplyState {
stage: UpdateStage,
target_version: Option<String>,
message: String,
started_unix: u64,
finished_at: Option<DateTime<Utc>>,
}
impl ApplyState {
fn idle() -> Self {
Self {
stage: UpdateStage::Idle,
target_version: None,
message: String::new(),
started_unix: 0,
finished_at: None,
}
}
}
pub struct UpdateApplier {
paths: HelperPaths,
allow_agent_apply: bool,
state: Mutex<ApplyState>,
}
pub struct ApplyPlan {
pub release: LatestRelease,
pub repo: String,
pub db: Arc<Db>,
pub snapshots_dir: PathBuf,
pub current_version: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}
impl UpdateApplier {
pub fn new(config: &UpdateConfig) -> Self {
Self::with_paths(config, HelperPaths::default())
}
pub fn with_paths(config: &UpdateConfig, paths: HelperPaths) -> Self {
Self {
paths,
allow_agent_apply: config.allow_agent_apply,
state: Mutex::new(ApplyState::idle()),
}
}
pub fn allow_agent_apply(&self) -> bool {
self.allow_agent_apply
}
pub fn is_helper_available(&self) -> bool {
self.paths.socket.exists()
}
pub fn try_begin(&self, target_version: &str) -> Result<(), String> {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if matches!(
state.stage,
UpdateStage::Downloading | UpdateStage::Installing
) {
return Err("já há uma atualização em andamento".into());
}
*state = ApplyState {
stage: UpdateStage::Downloading,
target_version: Some(target_version.to_string()),
message: "preparando".into(),
started_unix: now_unix(),
finished_at: None,
};
Ok(())
}
fn set(&self, stage: UpdateStage, message: impl Into<String>) {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.stage = stage;
state.message = message.into();
if matches!(
stage,
UpdateStage::Succeeded | UpdateStage::Failed | UpdateStage::RolledBack
) {
state.finished_at = Some(Utc::now());
}
}
pub fn status(&self, running_version: &str) -> UpdateProgress {
let memory = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
let outcome = read_outcome(&self.paths.outcome_file).ok().flatten();
let is_helper_available = self.is_helper_available();
let (stage, target_version, message, finished_at) = match memory.stage {
UpdateStage::Downloading => (memory.stage, memory.target_version, memory.message, None),
UpdateStage::Installing | UpdateStage::RollingBack => {
match outcome.filter(|outcome| outcome.at_unix >= memory.started_unix) {
Some(outcome) => {
from_outcome(&outcome, memory.target_version, running_version, true)
}
None => (memory.stage, memory.target_version, memory.message, None),
}
}
UpdateStage::Succeeded | UpdateStage::Failed | UpdateStage::RolledBack => (
memory.stage,
memory.target_version,
memory.message,
memory.finished_at,
),
UpdateStage::Idle => match outcome {
Some(outcome) => from_outcome(&outcome, None, running_version, false),
None => (UpdateStage::Idle, None, String::new(), None),
},
};
UpdateProgress {
stage,
target_version,
message,
is_helper_available,
finished_at,
}
}
pub async fn run(&self, plan: ApplyPlan) {
if let Err(message) = self.run_steps(&plan).await {
tracing::warn!(%message, "atualização do daemon falhou");
self.set(UpdateStage::Failed, message);
}
}
async fn run_steps(&self, plan: &ApplyPlan) -> Result<(), String> {
self.set(UpdateStage::Downloading, "tirando um snapshot do banco");
snapshot_database(&plan.db, &plan.snapshots_dir, &plan.current_version)
.await
.map_err(|error| format!("snapshot do banco: {error}"))?;
let (deb_asset, signature_asset) =
select_assets(&plan.release, &plan.repo, deb_architecture()?)?;
self.set(
UpdateStage::Downloading,
format!("baixando {}", deb_asset.name),
);
prepare_updates_dir(&self.paths.updates_dir)
.map_err(|error| format!("diretório de atualizações: {error}"))?;
let deb_path = self.paths.updates_dir.join(&deb_asset.name);
let signature_path = self.paths.updates_dir.join(&signature_asset.name);
let user_agent = format!("rustployd/{}", env!("CARGO_PKG_VERSION"));
let headers = [("user-agent", user_agent.as_str())];
https_client::download(
&deb_asset.url,
&headers,
&deb_path,
MAX_DEB_BYTES,
DOWNLOAD_TIMEOUT,
DOWNLOAD_HOSTS,
)
.await
.map_err(|error| format!("download do pacote: {error}"))?;
https_client::download(
&signature_asset.url,
&headers,
&signature_path,
MAX_SIGNATURE_BYTES,
DOWNLOAD_TIMEOUT,
DOWNLOAD_HOSTS,
)
.await
.map_err(|error| format!("download da assinatura: {error}"))?;
self.set(UpdateStage::Installing, "pedindo a instalação ao helper");
let request = serde_json::json!({
"op": "install",
"deb": deb_path.to_string_lossy(),
"sig": signature_path.to_string_lossy(),
});
let reply = helper_call(&self.paths.socket, &request, HELPER_ACCEPT_TIMEOUT).await?;
if !reply.ok {
return Err(reply
.error
.unwrap_or_else(|| "o helper recusou o pacote".into()));
}
self.set(
UpdateStage::Installing,
"instalando; o daemon vai reiniciar",
);
Ok(())
}
pub async fn rollback(&self) -> Result<(), String> {
if !self.is_helper_available() {
return Err("o helper de atualização não está disponível nesta máquina".into());
}
{
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*state = ApplyState {
stage: UpdateStage::RollingBack,
target_version: None,
message: "revertendo os binários".into(),
started_unix: now_unix(),
finished_at: None,
};
}
let reply = match helper_call(
&self.paths.socket,
&serde_json::json!({ "op": "rollback" }),
HELPER_ACCEPT_TIMEOUT,
)
.await
{
Ok(reply) => reply,
Err(message) => {
self.set(UpdateStage::Failed, message.clone());
return Err(message);
}
};
if reply.ok {
Ok(())
} else {
let message = reply
.error
.unwrap_or_else(|| "o helper recusou reverter".into());
self.set(UpdateStage::Failed, message.clone());
Err(message)
}
}
}
fn from_outcome(
outcome: &UpdateOutcome,
target_version: Option<String>,
running_version: &str,
from_this_attempt: bool,
) -> (UpdateStage, Option<String>, String, Option<DateTime<Utc>>) {
let finished_at = i64::try_from(outcome.at_unix)
.ok()
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0));
let target = target_version
.or_else(|| Some(outcome.version.clone()).filter(|version| !version.is_empty()));
match outcome.kind {
OutcomeKind::InProgress => {
let is_stale = now_unix().saturating_sub(outcome.at_unix) > STALE_IN_PROGRESS_SECS;
if is_stale && !from_this_attempt {
(UpdateStage::Idle, None, String::new(), None)
} else {
(
UpdateStage::Installing,
target,
outcome.message.clone(),
None,
)
}
}
OutcomeKind::Installed => {
if from_this_attempt || package_matches_running(&outcome.version, running_version) {
(
UpdateStage::Succeeded,
target,
outcome.message.clone(),
finished_at,
)
} else {
(UpdateStage::Idle, None, String::new(), None)
}
}
OutcomeKind::Failed => (
UpdateStage::Failed,
target,
outcome.message.clone(),
finished_at,
),
OutcomeKind::RolledBack => (
UpdateStage::RolledBack,
target,
outcome.message.clone(),
finished_at,
),
}
}
fn package_matches_running(package_version: &str, running_version: &str) -> bool {
let upstream = package_version
.rsplit_once('-')
.map(|(upstream, _revision)| upstream)
.unwrap_or(package_version);
upstream == running_version
}
fn deb_architecture() -> Result<&'static str, String> {
deb_architecture_of(std::env::consts::ARCH)
}
fn deb_architecture_of(rust_arch: &str) -> Result<&'static str, String> {
match rust_arch {
"x86_64" => Ok("amd64"),
"aarch64" => Ok("arm64"),
other => Err(format!("não há pacote para a arquitetura {other}")),
}
}
fn select_assets(
release: &LatestRelease,
repo: &str,
architecture: &str,
) -> Result<(ReleaseAsset, ReleaseAsset), String> {
let allowed_prefix = format!("https://github.com/{repo}/releases/download/");
let from_repo = |asset: &&ReleaseAsset| asset.url.starts_with(&allowed_prefix);
let suffix = format!("_{architecture}.deb");
let deb = release
.assets
.iter()
.filter(from_repo)
.find(|asset| asset.name.starts_with("rustployd_") && asset.name.ends_with(&suffix))
.ok_or_else(|| {
format!(
"a release {} não tem pacote do daemon para {architecture}",
release.tag
)
})?;
let signature_name = format!("{}.minisig", deb.name);
let signature = release
.assets
.iter()
.filter(from_repo)
.find(|asset| asset.name == signature_name)
.ok_or_else(|| {
format!(
"a release {} não tem a assinatura ({signature_name}); só instalo pacote assinado",
release.tag
)
})?;
Ok((deb.clone(), signature.clone()))
}
fn prepare_updates_dir(directory: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(directory)?;
for entry in std::fs::read_dir(directory)?.flatten() {
if entry.file_type().is_ok_and(|kind| kind.is_file()) {
let _ = std::fs::remove_file(entry.path());
}
}
Ok(())
}
pub async fn snapshot_database(db: &Db, directory: &Path, tag: &str) -> Result<PathBuf, String> {
std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
let safe_tag: String = tag
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || ".-+~".contains(character) {
character
} else {
'_'
}
})
.collect();
let target = directory.join(format!("pre-update-{safe_tag}-{}.db", now_unix()));
let target = unique_path(target);
sqlx::query("VACUUM INTO ?1")
.bind(target.to_string_lossy().into_owned())
.execute(db)
.await
.map_err(|error| error.to_string())?;
prune_snapshots(directory);
Ok(target)
}
fn unique_path(path: PathBuf) -> PathBuf {
if !path.exists() {
return path;
}
(1..)
.map(|attempt| path.with_extension(format!("{attempt}.db")))
.find(|candidate| !candidate.exists())
.unwrap_or(path)
}
fn prune_snapshots(directory: &Path) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
let mut snapshots: Vec<(SystemTime, PathBuf)> = entries
.flatten()
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("pre-update-")
})
.filter_map(|entry| Some((entry.metadata().ok()?.modified().ok()?, entry.path())))
.collect();
snapshots.sort_by(|left, right| right.0.cmp(&left.0));
for (_, path) in snapshots.into_iter().skip(SNAPSHOTS_KEPT) {
let _ = std::fs::remove_file(path);
}
}
fn helper_io_error(error: std::io::Error) -> String {
use std::io::ErrorKind::{BrokenPipe, ConnectionReset, UnexpectedEof};
match error.kind() {
ConnectionReset | BrokenPipe | UnexpectedEof => {
"o helper fechou a conexão sem responder".to_string()
}
_ => format!("helper: {error}"),
}
}
#[derive(Debug, Deserialize)]
struct HelperReply {
ok: bool,
#[serde(default)]
error: Option<String>,
}
async fn helper_call(
socket: &Path,
request: &serde_json::Value,
timeout: Duration,
) -> Result<HelperReply, String> {
let exchange = async {
let stream = UnixStream::connect(socket).await.map_err(|error| {
format!(
"não consegui falar com o helper de atualização ({}): {error}",
socket.display()
)
})?;
let (reader, mut writer) = stream.into_split();
let mut line = request.to_string();
line.push('\n');
writer
.write_all(line.as_bytes())
.await
.map_err(helper_io_error)?;
let mut reply = String::new();
BufReader::new(reader)
.read_line(&mut reply)
.await
.map_err(helper_io_error)?;
if reply.trim().is_empty() {
return Err("o helper fechou a conexão sem responder".to_string());
}
serde_json::from_str::<HelperReply>(reply.trim())
.map_err(|error| format!("resposta ilegível do helper: {error}"))
};
tokio::time::timeout(timeout, exchange)
.await
.map_err(|_| "o helper de atualização não respondeu a tempo".to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
use update_core::outcome::write_outcome;
fn asset(name: &str, repo: &str) -> ReleaseAsset {
ReleaseAsset {
name: name.into(),
url: format!("https://github.com/{repo}/releases/download/v0.1.16/{name}"),
}
}
fn release(assets: Vec<ReleaseAsset>) -> LatestRelease {
LatestRelease {
tag: "v0.1.16".into(),
url: "https://github.com/o/r/releases/tag/v0.1.16".into(),
published_at: None,
assets,
}
}
fn full_release(repo: &str) -> LatestRelease {
release(
[
"checksums.sha256",
"rustploy-gui_0.1.20-1_amd64.deb",
"rustploy-gui_0.1.20-1_amd64.deb.minisig",
"rustployd_0.1.10-1_amd64.deb",
"rustployd_0.1.10-1_amd64.deb.minisig",
"rustployd_0.1.10-1_arm64.deb",
"rustployd_0.1.10-1_arm64.deb.minisig",
]
.map(|name| asset(name, repo))
.to_vec(),
)
}
#[test]
fn picks_the_daemon_package_for_this_architecture_and_its_signature() {
let (deb, signature) = select_assets(&full_release("o/r"), "o/r", "arm64").unwrap();
assert_eq!(deb.name, "rustployd_0.1.10-1_arm64.deb");
assert_eq!(signature.name, "rustployd_0.1.10-1_arm64.deb.minisig");
let (deb, _) = select_assets(&full_release("o/r"), "o/r", "amd64").unwrap();
assert_eq!(deb.name, "rustployd_0.1.10-1_amd64.deb");
}
#[test]
fn a_release_without_a_signature_is_not_installable() {
let mut release = full_release("o/r");
release
.assets
.retain(|asset| !asset.name.ends_with(".minisig"));
let error = select_assets(&release, "o/r", "amd64").unwrap_err();
assert!(error.contains("só instalo pacote assinado"), "{error}");
}
#[test]
fn a_release_without_a_package_for_the_architecture_is_refused() {
let error = select_assets(&full_release("o/r"), "o/r", "riscv64").unwrap_err();
assert!(error.contains("riscv64"), "{error}");
}
#[test]
fn urls_outside_the_configured_repo_are_ignored() {
let error = select_assets(&full_release("atacante/repo"), "o/r", "amd64").unwrap_err();
assert!(error.contains("não tem pacote"), "{error}");
let mut hostile = full_release("o/r");
for asset in &mut hostile.assets {
asset.url = format!("https://evil.example/{}", asset.name);
}
assert!(select_assets(&hostile, "o/r", "amd64").is_err());
}
#[test]
fn rust_architectures_map_to_debian_ones() {
assert_eq!(deb_architecture_of("x86_64"), Ok("amd64"));
assert_eq!(deb_architecture_of("aarch64"), Ok("arm64"));
assert!(deb_architecture_of("riscv64").is_err());
}
#[test]
fn package_version_matches_the_crate_version() {
assert!(package_matches_running("0.1.10-1", "0.1.10"));
assert!(!package_matches_running("0.1.10-1", "0.1.9"));
assert!(!package_matches_running("0.1.9-1", "0.1.10"));
assert!(package_matches_running("0.1.10", "0.1.10"));
}
fn applier(directory: &Path) -> UpdateApplier {
UpdateApplier::with_paths(
&UpdateConfig::default(),
HelperPaths {
socket: directory.join("update.sock"),
updates_dir: directory.join("updates"),
outcome_file: directory.join("last.json"),
},
)
}
fn outcome(directory: &Path, version: &str, kind: OutcomeKind, message: &str, age_secs: u64) {
let mut outcome = UpdateOutcome::new("rustployd", version, kind, message);
outcome.at_unix = now_unix() - age_secs;
write_outcome(&directory.join("last.json"), &outcome).unwrap();
}
#[test]
fn idle_with_no_history_says_nothing() {
let directory = tempfile::tempdir().unwrap();
let status = applier(directory.path()).status("0.1.10");
assert_eq!(status.stage, UpdateStage::Idle);
assert!(status.message.is_empty());
assert!(!status.is_helper_available);
}
#[test]
fn after_a_restart_a_matching_install_is_reported_as_success() {
let directory = tempfile::tempdir().unwrap();
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::Installed,
"instalado",
5,
);
let status = applier(directory.path()).status("0.1.10");
assert_eq!(status.stage, UpdateStage::Succeeded);
assert_eq!(status.target_version.as_deref(), Some("0.1.10-1"));
assert!(status.finished_at.is_some());
}
#[test]
fn an_old_install_result_is_not_news_after_a_later_version_runs() {
let directory = tempfile::tempdir().unwrap();
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::Installed,
"instalado",
99_999,
);
assert_eq!(
applier(directory.path()).status("0.1.11").stage,
UpdateStage::Idle
);
}
#[test]
fn failures_and_rollbacks_carry_their_cause() {
let directory = tempfile::tempdir().unwrap();
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::Failed,
"assinatura inválida",
5,
);
let failed = applier(directory.path()).status("0.1.9");
assert_eq!(failed.stage, UpdateStage::Failed);
assert_eq!(failed.message, "assinatura inválida");
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::RolledBack,
"serviço caiu",
5,
);
assert_eq!(
applier(directory.path()).status("0.1.9").stage,
UpdateStage::RolledBack
);
}
#[test]
fn a_live_in_progress_marker_is_installing_but_a_stale_one_is_not() {
let directory = tempfile::tempdir().unwrap();
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::InProgress,
"instalando",
10,
);
assert_eq!(
applier(directory.path()).status("0.1.9").stage,
UpdateStage::Installing
);
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::InProgress,
"instalando",
STALE_IN_PROGRESS_SECS + 60,
);
assert_eq!(
applier(directory.path()).status("0.1.9").stage,
UpdateStage::Idle
);
}
#[test]
fn only_one_attempt_runs_at_a_time_and_a_finished_one_can_be_retried() {
let directory = tempfile::tempdir().unwrap();
let applier = applier(directory.path());
applier.try_begin("0.1.10").unwrap();
assert!(applier.try_begin("0.1.10").is_err());
assert_eq!(applier.status("0.1.9").stage, UpdateStage::Downloading);
applier.set(UpdateStage::Failed, "sem rede");
let status = applier.status("0.1.9");
assert_eq!(status.stage, UpdateStage::Failed);
assert_eq!(status.message, "sem rede");
assert!(status.finished_at.is_some());
assert!(applier.try_begin("0.1.10").is_ok());
}
#[test]
fn while_installing_the_helpers_verdict_for_this_attempt_wins() {
let directory = tempfile::tempdir().unwrap();
let applier = applier(directory.path());
applier.try_begin("0.1.10").unwrap();
applier.set(
UpdateStage::Installing,
"instalando; o daemon vai reiniciar",
);
assert_eq!(applier.status("0.1.9").stage, UpdateStage::Installing);
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::RolledBack,
"serviço caiu",
0,
);
let status = applier.status("0.1.9");
assert_eq!(status.stage, UpdateStage::RolledBack);
assert_eq!(status.message, "serviço caiu");
}
#[tokio::test]
async fn a_rollback_ignores_the_result_of_an_older_install_until_its_own_arrives() {
let directory = tempfile::tempdir().unwrap();
outcome(
directory.path(),
"0.1.10-1",
OutcomeKind::Installed,
"instalado",
120,
);
let socket = directory.path().join("update.sock");
let listener = tokio::net::UnixListener::bind(&socket).unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (reader, mut writer) = stream.into_split();
let mut request = String::new();
BufReader::new(reader)
.read_line(&mut request)
.await
.unwrap();
writer.write_all(b"{\"ok\":true}\n").await.unwrap();
});
let applier = applier(directory.path());
applier.rollback().await.unwrap();
let status = applier.status("0.1.10");
assert_eq!(status.stage, UpdateStage::RollingBack);
outcome(
directory.path(),
"0.1.9-1",
OutcomeKind::RolledBack,
"restaurado",
0,
);
let status = applier.status("0.1.9");
assert_eq!(status.stage, UpdateStage::RolledBack);
assert_eq!(status.message, "restaurado");
}
#[tokio::test]
async fn a_refused_rollback_becomes_a_failed_status_with_the_reason() {
let directory = tempfile::tempdir().unwrap();
let (socket, _task) = fake_helper(
directory.path(),
"{\"ok\":false,\"error\":\"não há backup\"}\n",
)
.await;
let mut paths = HelperPaths::default();
paths.socket = socket;
paths.outcome_file = directory.path().join("last.json");
let applier = UpdateApplier::with_paths(&UpdateConfig::default(), paths);
assert_eq!(applier.rollback().await.unwrap_err(), "não há backup");
let status = applier.status("0.1.9");
assert_eq!(status.stage, UpdateStage::Failed);
assert_eq!(status.message, "não há backup");
}
#[test]
fn leftovers_in_the_updates_dir_are_cleared() {
let directory = tempfile::tempdir().unwrap();
let updates = directory.path().join("updates");
std::fs::create_dir_all(&updates).unwrap();
std::fs::write(updates.join("rustployd_0.1.9-1_amd64.deb"), b"velho").unwrap();
std::fs::write(updates.join("x.deb.partial"), b"meio").unwrap();
prepare_updates_dir(&updates).unwrap();
assert_eq!(std::fs::read_dir(&updates).unwrap().count(), 0);
}
#[tokio::test]
async fn the_database_snapshot_is_a_real_database_and_only_the_latest_are_kept() {
let directory = tempfile::tempdir().unwrap();
let db = crate::store::connect(&directory.path().join("db"))
.await
.unwrap();
let snapshots = directory.path().join("snapshots");
let mut last = None;
for _ in 0..5 {
last = Some(
snapshot_database(&db, &snapshots, "0.1.9/../x")
.await
.unwrap(),
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let kept = std::fs::read_dir(&snapshots).unwrap().count();
assert_eq!(kept, SNAPSHOTS_KEPT);
let last = last.unwrap();
assert!(!last.file_name().unwrap().to_string_lossy().contains('/'));
let copy = sqlx::sqlite::SqlitePoolOptions::new()
.connect(&format!("sqlite://{}", last.display()))
.await
.unwrap();
let tables: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'service'",
)
.fetch_one(©)
.await
.unwrap();
assert_eq!(tables, 1);
}
async fn fake_helper(
directory: &Path,
reply: &'static str,
) -> (PathBuf, tokio::task::JoinHandle<String>) {
let socket = directory.join("helper.sock");
let listener = tokio::net::UnixListener::bind(&socket).unwrap();
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (reader, mut writer) = stream.into_split();
let mut request = String::new();
BufReader::new(reader)
.read_line(&mut request)
.await
.unwrap();
writer.write_all(reply.as_bytes()).await.unwrap();
request
});
(socket, task)
}
#[tokio::test]
async fn the_helper_request_is_one_json_line_with_the_two_paths() {
let directory = tempfile::tempdir().unwrap();
let (socket, task) =
fake_helper(directory.path(), "{\"ok\":true,\"version\":\"0.1.10-1\"}\n").await;
let request =
serde_json::json!({"op": "install", "deb": "/a.deb", "sig": "/a.deb.minisig"});
let reply = helper_call(&socket, &request, Duration::from_secs(5))
.await
.unwrap();
assert!(reply.ok);
let received = task.await.unwrap();
assert_eq!(received.matches('\n').count(), 1);
let parsed: serde_json::Value = serde_json::from_str(received.trim()).unwrap();
assert_eq!(parsed["op"], "install");
assert_eq!(parsed["deb"], "/a.deb");
}
#[tokio::test]
async fn a_refusal_from_the_helper_surfaces_its_reason() {
let directory = tempfile::tempdir().unwrap();
let (socket, _task) = fake_helper(
directory.path(),
"{\"ok\":false,\"error\":\"assinatura inválida\"}\n",
)
.await;
let reply = helper_call(
&socket,
&serde_json::json!({"op": "rollback"}),
Duration::from_secs(5),
)
.await
.unwrap();
assert!(!reply.ok);
assert_eq!(reply.error.as_deref(), Some("assinatura inválida"));
}
#[tokio::test]
async fn a_missing_or_silent_helper_is_a_clear_error() {
let directory = tempfile::tempdir().unwrap();
let request = serde_json::json!({"op": "rollback"});
let missing = helper_call(
&directory.path().join("nao-existe.sock"),
&request,
Duration::from_secs(5),
)
.await
.unwrap_err();
assert!(
missing.contains("não consegui falar com o helper"),
"{missing}"
);
let socket = directory.path().join("mudo.sock");
let listener = tokio::net::UnixListener::bind(&socket).unwrap();
tokio::spawn(async move {
let _ = listener.accept().await;
});
let silent = helper_call(&socket, &request, Duration::from_secs(5))
.await
.unwrap_err();
assert!(silent.contains("sem responder"), "{silent}");
}
}