Closes M12. The phone can now notice that its server has a newer build and install it, instead of the operator copying an APK to a device by hand. **A PackageInstaller session, not an install intent.** The obvious route — ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned against, and it is what produced the "bypassing Android security" warning on Minstrel (Scribe note 2437). It also never tells the OS that this app is the legitimate updater of its own package, and it returns nothing: a failed install is indistinguishable from someone dismissing the dialog. The session says who is doing what, and on Android 12+ declares no user action required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the confirmation entirely on the UPDATE path. Only there: Android will not let an app quietly put a NEW package on a device, which is right. It also only applies when the new build carries the same signing key as the installed one, which is why signing had to land first. Two things from that research deliberately NOT done: `setRequestUpdateOwnership` was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is not the differentiator either — Mihon declares it too. The mechanism was the whole difference. **The outcome comes back.** `commit` takes an IntentSender and the result lands at `UpdateReceiver`, so a failure can be shown rather than guessed at, and STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31 and still possible above it, since the OS is entitled to ask anyway. Someone declining is reported as no error at all: calling a deliberate choice a failure is how an app sounds broken when it is not. **The network work stays in Rust.** Two FFI additions — `clientUpdate` and `downloadClientUpdate` — because the device token lives in the core, and pulling it into Kotlin to make an HTTP call would spread the one secret this app holds across two languages for nothing. The core also owns the comparison, so the rule "version CODE decides, never the name" lives in the layer that has to get it right for every surface. The download is streamed to disk, not buffered: 55 MiB in memory on a phone is how an update gets killed halfway through. It lands in `update.apk.part` and is renamed only once size and sha256 both match, so an interrupted download can never be mistaken for a finished one. The digest is not a trust anchor — the signature is, and Android checks it — but it catches a truncated transfer before the installer is bothered with it. The advertised path is joined to the base URL this device is LINKED to rather than followed as given, so a server cannot point the download at a host nobody agreed to. **Updates are linked-only, and it says so.** An unlinked install has no update path, so it gets one sentence explaining where updates come from rather than a Check button that silently finds nothing — the same lesson as the desktop's unlink copy (issue 2110). And the "install unknown apps" grant is asked for BEFORE downloading, so nobody spends 55 MiB to be told no. Every Android API here was read out of `android-36/android.jar` with javap first, and the two new FFI methods out of freshly generated bindings, rather than recalled: `suspend fun clientUpdate(installedVersionCode: Long): ClientUpdate?` and `downloadClientUpdate(destPath: String)`. Also fixes `check-symbols.py`, which reported four false positives on `UpdateOutcome.Result` — its object-member index collected functions and properties but not nested TYPES, and a data class inside an object is an ordinary member.
570 lines
20 KiB
Rust
570 lines
20 KiB
Rust
//! HTTP transport to a ThoughtSync server.
|
|
//!
|
|
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
|
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
|
//! which is why the timeout, identity headers and error vocabulary live here rather
|
|
//! than inline at each call site.
|
|
//!
|
|
//! Nothing here runs unless the user has linked a server; the app is local-first and
|
|
//! fully usable with no network at all.
|
|
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use reqwest::{RequestBuilder, StatusCode};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::compat::{self, Compatibility, ServerInfo};
|
|
use super::wire;
|
|
|
|
/// Timeout for the short request/response calls in this module. Kept tight because a
|
|
/// user is watching a button while they run, and the most common mistake — a wrong
|
|
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
|
|
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
|
|
/// failing one at ten seconds would make a large store impossible to ever pull.
|
|
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
|
|
|
|
/// Shared by every call that presents a token, so a revoked one reads the same way
|
|
/// wherever it surfaces.
|
|
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
|
|
revoked. Unlink and link again to issue a new one.";
|
|
|
|
/// What the link UI needs after a handshake: where we ended up (the normalized URL,
|
|
/// which may differ from what was typed), who answered, and whether we can work
|
|
/// with them.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProbeResult {
|
|
pub base_url: String,
|
|
pub server: ServerInfo,
|
|
pub compatibility: Compatibility,
|
|
}
|
|
|
|
/// The account a device token belongs to. Surfaced after linking so the user can
|
|
/// confirm they linked the account they meant to — easy to get wrong on a server
|
|
/// hosting more than one.
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Identity {
|
|
pub id: String,
|
|
pub email: String,
|
|
#[serde(default)]
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DeviceLoginResponse {
|
|
token: String,
|
|
user: Identity,
|
|
}
|
|
|
|
/// What became of this device's token on the SERVER when unlinking.
|
|
///
|
|
/// Not a bool, and not an error: unlinking must never be blocked by the network —
|
|
/// wanting to stop syncing is a local decision — so the remote half reports back
|
|
/// instead of failing the call, and each outcome needs different advice.
|
|
///
|
|
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(tag = "status", rename_all = "snake_case")]
|
|
pub enum RevokeOutcome {
|
|
/// The server confirmed it: this token authenticates nothing now.
|
|
Revoked,
|
|
/// This server has no self-revoke route — it predates one. The token is still
|
|
/// live, and only the web app can retire it.
|
|
Unsupported,
|
|
/// We couldn't reach the server, or it refused. The token is still live.
|
|
Failed { reason: String },
|
|
/// Nothing to revoke; the app wasn't linked.
|
|
Skipped,
|
|
}
|
|
|
|
/// Retire the device token we authenticate with, server-side.
|
|
///
|
|
/// Identified by the token itself rather than a device id, because a token pasted
|
|
/// from the web app never carried one — a route keyed on the id would work for
|
|
/// exactly one of the two ways this app can be linked.
|
|
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
|
|
let client = match http() {
|
|
Ok(client) => client,
|
|
Err(reason) => return RevokeOutcome::Failed { reason },
|
|
};
|
|
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
|
|
let response = match request.send().await {
|
|
Ok(response) => response,
|
|
Err(e) => {
|
|
return RevokeOutcome::Failed {
|
|
reason: describe_transport_error(base_url, &e),
|
|
}
|
|
}
|
|
};
|
|
|
|
let status = response.status();
|
|
// 401 counts as revoked: the token already authenticates nothing — retired by
|
|
// another device, or purged server-side — which is the state we were asking for.
|
|
if status.is_success() || status == StatusCode::UNAUTHORIZED {
|
|
return RevokeOutcome::Revoked;
|
|
}
|
|
match status {
|
|
// No such route: a server older than self-revoke. Any other shape of 404
|
|
// (a proxy, a stale base URL) leaves the token live too, so the advice the
|
|
// user needs is the same either way.
|
|
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
|
|
other => RevokeOutcome::Failed {
|
|
reason: unexpected_status(base_url, other),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
|
|
reqwest::Client::builder()
|
|
.timeout(timeout)
|
|
.build()
|
|
.map_err(|e| format!("Could not start the network client: {e}"))
|
|
}
|
|
|
|
fn http() -> Result<reqwest::Client, String> {
|
|
http_with(REQUEST_TIMEOUT)
|
|
}
|
|
|
|
/// Attach the client-identity headers every request carries, plus a bearer token
|
|
/// when we hold one.
|
|
fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder {
|
|
let mut builder = builder;
|
|
for (name, value) in compat::client_headers() {
|
|
builder = builder.header(name, value);
|
|
}
|
|
match token {
|
|
Some(t) => builder.bearer_auth(t),
|
|
None => builder,
|
|
}
|
|
}
|
|
|
|
fn unexpected_status(base_url: &str, status: StatusCode) -> String {
|
|
format!(
|
|
"{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \
|
|
different site may be answering there.",
|
|
status.as_u16()
|
|
)
|
|
}
|
|
|
|
/// Ask a server who it is and whether we can sync with it.
|
|
///
|
|
/// `Err` means we never got a usable answer (bad address, unreachable, not a
|
|
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
|
|
/// with a verdict — that distinction matters, because the two need very different
|
|
/// messages: one is "check what you typed", the other is "update something".
|
|
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
|
|
let base_url = compat::normalize_base_url(raw_url)
|
|
.ok_or("Enter a server address, like https://notes.example.com")?;
|
|
|
|
let request = prepare(http()?.get(config_url(&base_url)), None);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(&base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(&base_url, status));
|
|
}
|
|
|
|
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
|
|
// captive portal). Report the address, not the parse error, which would mean
|
|
// nothing to the person reading it.
|
|
let server: ServerInfo = response.json().await.map_err(|_| {
|
|
format!(
|
|
"{base_url} responded, but not with ThoughtSync's configuration. \
|
|
Is that the right address?"
|
|
)
|
|
})?;
|
|
|
|
let compatibility = compat::evaluate(&server);
|
|
Ok(ProbeResult {
|
|
base_url,
|
|
server,
|
|
compatibility,
|
|
})
|
|
}
|
|
|
|
/// Exchange email + password for a device bearer token.
|
|
///
|
|
/// The fresh-install path: it needs no existing session, which is what lets a brand
|
|
/// new desktop install link without visiting the web app first.
|
|
pub async fn device_login(
|
|
base_url: &str,
|
|
email: &str,
|
|
password: &str,
|
|
device_name: &str,
|
|
) -> Result<(String, Identity), String> {
|
|
let body = serde_json::json!({
|
|
"email": email,
|
|
"password": password,
|
|
"name": device_name,
|
|
});
|
|
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err("That email and password didn't match an account on this server.".to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
let parsed: DeviceLoginResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?;
|
|
Ok((parsed.token, parsed.user))
|
|
}
|
|
|
|
/// Validate a token by asking whom it belongs to.
|
|
///
|
|
/// Used when the user pastes a token issued from the web app. Storing it unverified
|
|
/// would turn a copy/paste slip into a failure that only surfaces at the next sync,
|
|
/// far from the thing that caused it.
|
|
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
|
|
let request = prepare(http()?.get(me_url(base_url)), Some(token));
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
let message = "That token isn't valid on this server — it may have been revoked. \
|
|
Issue a new one from the web app under Account → Linked devices.";
|
|
return Err(message.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply."))
|
|
}
|
|
|
|
/// Fetch one page of the change feed, starting after `since`.
|
|
///
|
|
/// The caller loops until `has_more` is false (see `pull::run`); paging lives there
|
|
/// rather than here so the transport stays a single request/response.
|
|
pub async fn fetch_changes(
|
|
base_url: &str,
|
|
token: &str,
|
|
since: i64,
|
|
) -> Result<wire::ChangesPage, String> {
|
|
let url = format!("{base_url}/api/sync/changes?since={since}");
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}"))
|
|
}
|
|
|
|
/// Download one attachment's bytes.
|
|
///
|
|
/// Metadata already arrived on the delta feed; this is only the payload, fetched
|
|
/// over the same route the web app uses (owner/shared scoped server-side).
|
|
pub async fn fetch_attachment(
|
|
base_url: &str,
|
|
token: &str,
|
|
note_id: &str,
|
|
attachment_id: &str,
|
|
) -> Result<Vec<u8>, String> {
|
|
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.bytes()
|
|
.await
|
|
.map(|b| b.to_vec())
|
|
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
|
|
}
|
|
|
|
/// Send a batch of changes and hand back the raw reply.
|
|
///
|
|
/// Returns text rather than parsed results so this module stays pure transport —
|
|
/// `push::parse_results` owns the result shapes, and keeping them there is what lets
|
|
/// the parsing be unit-tested without a server.
|
|
pub async fn push_changes<T: Serialize>(
|
|
base_url: &str,
|
|
token: &str,
|
|
changes: &[T],
|
|
) -> Result<String, String> {
|
|
let body = serde_json::json!({ "changes": changes });
|
|
let url = format!("{base_url}/api/sync/push");
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.text()
|
|
.await
|
|
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
|
}
|
|
|
|
/// The public, unauthenticated endpoint carrying the handshake.
|
|
fn config_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/config")
|
|
}
|
|
|
|
fn device_login_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/auth/device-login")
|
|
}
|
|
|
|
fn me_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/auth/me")
|
|
}
|
|
|
|
/// `self` rather than a device id: see `revoke_self`.
|
|
fn revoke_self_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/auth/devices/self")
|
|
}
|
|
|
|
/// Turn a transport failure into something a person can act on. reqwest's own
|
|
/// Display is accurate but reads like a stack trace.
|
|
fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
|
|
if err.is_timeout() {
|
|
// No specific duration here: these calls run under two different budgets
|
|
// (interactive vs bulk sync), and naming the wrong one is worse than naming
|
|
// none.
|
|
format!(
|
|
"{base_url} didn't respond in time. It may be offline, or unreachable \
|
|
from this network."
|
|
)
|
|
} else if err.is_connect() {
|
|
format!(
|
|
"Couldn't reach {base_url}. Check the address and that the server is \
|
|
running. If it uses plain HTTP, include http:// explicitly."
|
|
)
|
|
} else {
|
|
format!("Couldn't reach {base_url}: {err}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn urls_join_without_doubling_slashes() {
|
|
// normalize_base_url has already stripped any trailing slash, so plain
|
|
// concatenation is correct — this pins that assumption.
|
|
assert_eq!(
|
|
config_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/config"
|
|
);
|
|
assert_eq!(
|
|
device_login_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/auth/device-login"
|
|
);
|
|
assert_eq!(
|
|
me_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/auth/me"
|
|
);
|
|
assert_eq!(
|
|
revoke_self_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/auth/devices/self"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn revoke_outcome_serializes_tagged_for_the_frontend() {
|
|
// The UI decides between "signed out on the server" and "still valid, go
|
|
// revoke it" by reading this tag, so its shape is part of the contract.
|
|
let json = serde_json::to_string(&RevokeOutcome::Failed {
|
|
reason: "offline".into(),
|
|
})
|
|
.expect("outcome serializes");
|
|
assert!(json.contains("\"status\":\"failed\""), "got {json}");
|
|
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
|
|
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
|
|
}
|
|
|
|
#[test]
|
|
fn urls_preserve_a_port_and_subpath() {
|
|
assert_eq!(
|
|
config_url("http://192.168.1.10:8000/thoughtsync"),
|
|
"http://192.168.1.10:8000/thoughtsync/api/config"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The Android client a linked server can hand out.
|
|
///
|
|
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
|
|
/// means the server has no client to offer, which is an ordinary state and not an
|
|
/// error — a self-hoster who never touches Android has one.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ClientRelease {
|
|
pub version: String,
|
|
/// What decides "is this newer". The name is for people and sorts like a string.
|
|
pub version_code: i64,
|
|
pub size: i64,
|
|
pub sha256: String,
|
|
/// Path on the same server, not an absolute URL — the client joins it to the
|
|
/// base it is already linked to, so a compromised or misconfigured server
|
|
/// cannot redirect the download somewhere else.
|
|
pub url: String,
|
|
}
|
|
|
|
/// What Android client the linked server has, if any.
|
|
///
|
|
/// `Ok(None)` for a server that simply has none — that is the answer to the
|
|
/// question, not a failure to answer it.
|
|
pub async fn fetch_client_release(
|
|
base_url: &str,
|
|
token: &str,
|
|
) -> Result<Option<ClientRelease>, String> {
|
|
let url = format!("{base_url}/api/client/android");
|
|
let response = prepare(http()?.get(url), Some(token))
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::NOT_FOUND {
|
|
return Ok(None);
|
|
}
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.json::<ClientRelease>()
|
|
.await
|
|
.map(Some)
|
|
.map_err(|e| {
|
|
format!("{base_url} described its Android client in a way this app could not read: {e}")
|
|
})
|
|
}
|
|
|
|
/// Download the client to `dest`, verifying it on the way in.
|
|
///
|
|
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
|
|
/// a phone, on top of whatever the app is already using, is how an update gets
|
|
/// killed by the low-memory killer half way through.
|
|
///
|
|
/// Written to `dest.part` and renamed only once the digest matches, so an
|
|
/// interrupted download can never be mistaken for a finished one. The digest is
|
|
/// not a trust anchor — the APK signature is, and Android checks that at install —
|
|
/// but it catches a truncated or corrupted transfer before the installer is
|
|
/// bothered with it.
|
|
pub async fn download_client(
|
|
base_url: &str,
|
|
token: &str,
|
|
release: &ClientRelease,
|
|
dest: &Path,
|
|
) -> Result<(), String> {
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::Write;
|
|
|
|
// The advertised path is joined to the base we are LINKED to. Taking an
|
|
// absolute URL from the response would let a server point the download at a
|
|
// host the user never agreed to.
|
|
let path = release.url.trim_start_matches('/');
|
|
let url = format!("{base_url}/{path}");
|
|
|
|
let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token))
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
let partial = dest.with_extension("part");
|
|
if let Some(parent) = partial.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
|
|
}
|
|
let mut file = std::fs::File::create(&partial)
|
|
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
|
|
|
|
let mut hasher = Sha256::new();
|
|
let mut written: i64 = 0;
|
|
loop {
|
|
let chunk = response
|
|
.chunk()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
let Some(chunk) = chunk else { break };
|
|
hasher.update(&chunk);
|
|
written += chunk.len() as i64;
|
|
file.write_all(&chunk)
|
|
.map_err(|e| format!("Couldn't write the download: {e}"))?;
|
|
}
|
|
file.flush()
|
|
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
|
|
drop(file);
|
|
|
|
let digest = format!("{:x}", hasher.finalize());
|
|
let mismatch = if written != release.size {
|
|
Some(format!("expected {} bytes, got {written}", release.size))
|
|
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
|
|
Some("the contents did not match the checksum the server published".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(why) = mismatch {
|
|
// The half-file is removed rather than left: a later run finding it would
|
|
// have no way to tell it from a good one.
|
|
let _ = std::fs::remove_file(&partial);
|
|
return Err(format!("The download from {base_url} was damaged — {why}."));
|
|
}
|
|
|
|
std::fs::rename(&partial, dest)
|
|
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
|
|
}
|