Vectrum
Sign In
661 lines · 26.5 KB · Rust
//! Updates the app from its releases on Vectrum (vectrum.cc/vectrum/code),
//! the way T3 Code's desktop app does: it looks for a new version now and
//! then (or when asked), downloads it when the user says so, and installs it
//! when they restart.
//!
//! Only an AppImage updates itself; elsewhere a new version opens its
//! release page. A release must carry `SHA256SUMS` and `SHA256SUMS.sig`, an
//! Ed25519 signature over the sums made with the private half of a key in
//! [`RELEASE_KEYS`] (`build.sh -r` signs). The AppImage, and the release
//! notes in `CHANGES.md`, must hash to what the signed sums say, so neither
//! the server nor anything between can slip in another file. A download is
//! staged beside the AppImage and moved over it when the app restarts or
//! quits.

use std::{
    fs,
    io::{Read as _, Write as _},
    path::{Path, PathBuf},
    time::Duration,
};

use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use gpui_kit::*;
use serde::Deserialize;

use crate::settings;

/// Where releases come from. `VECTRUM_UPDATE_URL` points elsewhere, as a
/// test server does; releases from there still need the signature.
const SERVER: &str = "https://vectrum.cc";
const REPO: &str = "vectrum/code";
/// The public halves of the keys releases are signed with, base64. More than
/// one lets a new key take over.
const RELEASE_KEYS: &[&str] = &["wPnCiqfvFFXBY1PwhnGhcyK8ELkKasxlzSwMpZBDNxU="];
/// How long after starting the first look for a new version waits, and how
/// often it looks again.
const FIRST_CHECK: Duration = Duration::from_secs(15);
const CHECK_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
/// Downloads larger than this are refused, whatever the server says.
const MAX_SIZE: u64 = 512 * 1024 * 1024;
/// How many changes the release notes list before "N more".
pub const NOTES_SHOWN: usize = 12;

pub fn current_version() -> &'static str {
    env!("CARGO_PKG_VERSION")
}

/// A newer version on the server, checked against the signed sums.
#[derive(Clone, Debug, PartialEq)]
pub struct Release {
    pub version: String,
    /// Its page on the server.
    pub page: String,
    /// What changed, one line each.
    pub notes: Vec<String>,
    appimage_url: String,
    /// The AppImage's SHA-256, from the signed sums.
    sha256: Vec<u8>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub enum Stage {
    #[default]
    Idle,
    Checking,
    Available(Release),
    Downloading { release: Release, done: u64, total: u64 },
    /// Downloaded, checked and staged; a restart installs it.
    Ready(Release),
    /// A check or download went wrong; `release` is what it was getting.
    Failed { release: Option<Release>, error: String },
}

impl Stage {
    pub fn release(&self) -> Option<&Release> {
        match self {
            Stage::Available(release) | Stage::Ready(release) | Stage::Downloading { release, .. } => Some(release),
            Stage::Failed { release, .. } => release.as_ref(),
            Stage::Idle | Stage::Checking => None,
        }
    }
}

/// The app's updater, a global the views watch.
#[derive(Default)]
pub struct Updater {
    pub stage: Stage,
    /// Said once after a check or download the user started, for a notice,
    /// and whether it's an error.
    notice: Option<(String, bool)>,
    /// Restart as soon as the download is ready: a client asked this host
    /// to update.
    restart_when_ready: bool,
    _task: Option<Task<()>>,
    _schedule: Option<Task<()>>,
    _quit: Option<Subscription>,
}

impl Global for Updater {}

impl Updater {
    pub fn init(cx: &mut App) {
        let schedule = cx.spawn(async move |cx: &mut AsyncApp| {
            cx.background_executor().timer(FIRST_CHECK).await;
            loop {
                cx.update(|cx| {
                    if settings::get(|settings| settings.app_update_checks) {
                        Self::check(false, cx);
                    }
                });
                cx.background_executor().timer(CHECK_EVERY).await;
            }
        });
        // An update downloaded but not restarted into goes in on the way out,
        // so the next start is the new version.
        let quit = cx.on_app_quit(|cx| {
            if let Stage::Ready(release) = &cx.global::<Updater>().stage
                && let Some(target) = target()
                && let Err(err) = install(&target, &release.version)
            {
                eprintln!("vectrum-code: couldn't install the update: {err}");
            }
            async {}
        });
        cx.set_global(Updater { _schedule: Some(schedule), _quit: Some(quit), ..Default::default() });
    }

    pub fn global(cx: &App) -> &Updater {
        cx.global::<Updater>()
    }

    /// Takes what a check or download the user started has to say.
    pub fn take_notice(cx: &mut App) -> Option<(String, bool)> {
        // Only touched when there's one, since touching it tells the watchers.
        cx.global::<Updater>().notice.as_ref()?;
        cx.global_mut::<Updater>().notice.take()
    }

    /// Whether the app can replace itself: it runs as an AppImage.
    pub fn can_install() -> bool {
        target().is_some()
    }

    /// Looks for a newer version. A check the user asked for (`manual`)
    /// says how it went; an automatic one leaves a download or a staged
    /// update be.
    pub fn check(manual: bool, cx: &mut App) {
        let updater = cx.global::<Updater>();
        match &updater.stage {
            Stage::Checking | Stage::Downloading { .. } => return,
            Stage::Ready(_) if !manual => return,
            _ => {}
        }
        let previous = updater.stage.clone();
        Self::set_stage(Stage::Checking, cx);
        let task = cx.spawn(async move |cx: &mut AsyncApp| {
            let found = cx.background_spawn(async { latest(&server()) }).await;
            cx.update(|cx| {
                let updater = cx.global_mut::<Updater>();
                updater.stage = match found {
                    // A staged update of the same version stays ready.
                    Ok(Some(release)) => match previous {
                        Stage::Ready(staged) if staged.version == release.version => Stage::Ready(staged),
                        _ => Stage::Available(release),
                    },
                    Ok(None) => {
                        if manual {
                            updater.notice = Some((format!("Vectrum Code {} is the latest version.", current_version()), false));
                        }
                        Stage::Idle
                    }
                    Err(error) => {
                        if manual {
                            updater.notice = Some((format!("Couldn't check for updates: {error}"), true));
                        }
                        if manual || updater.restart_when_ready {
                            Stage::Failed { release: None, error }
                        } else {
                            // A missed automatic check is no news.
                            match previous {
                                Stage::Checking => Stage::Idle,
                                previous => previous,
                            }
                        }
                    }
                };
                let restart = updater.restart_when_ready;
                if restart {
                    Self::continue_remote_update(cx);
                }
            });
        });
        cx.global_mut::<Updater>()._task = Some(task);
    }

    /// Downloads the available update and stages it beside the AppImage.
    pub fn download(cx: &mut App) {
        let Some(release) = cx.global::<Updater>().stage.release().cloned() else { return };
        if matches!(cx.global::<Updater>().stage, Stage::Downloading { .. } | Stage::Ready(_)) {
            return;
        }
        let Some(target) = target() else {
            crate::ui::workspace::open_url(&release.page);
            return;
        };
        let (progress_tx, progress) = smol::channel::unbounded::<(u64, u64)>();
        let (done_tx, done) = smol::channel::bounded::<Result<(), String>>(1);
        {
            let fetching = release.clone();
            let spawned = std::thread::Builder::new().name("app-update".into()).spawn(move || {
                let result = fetch_and_stage(&fetching, &target, |done, total| {
                    let _ = progress_tx.try_send((done, total));
                });
                let _ = done_tx.send_blocking(result);
            });
            if let Err(err) = spawned {
                Self::set_stage(Stage::Failed { release: Some(release), error: err.to_string() }, cx);
                return;
            }
        }
        Self::set_stage(Stage::Downloading { release: release.clone(), done: 0, total: 0 }, cx);
        let task = cx.spawn(async move |cx: &mut AsyncApp| {
            loop {
                let next = smol::future::or(async { Err(done.recv().await) }, async { Ok(progress.recv().await) }).await;
                match next {
                    Ok(Ok((done, total))) => cx.update(|cx| {
                        // Told on whole percents, not on every packet.
                        let percent = |done: u64, total: u64| (done * 100).checked_div(total).unwrap_or(0);
                        if let Stage::Downloading { done: was, total: of, .. } = &cx.global::<Updater>().stage
                            && percent(done, total) != percent(*was, *of)
                            && let Stage::Downloading { done: was, total: of, .. } = &mut cx.global_mut::<Updater>().stage
                        {
                            (*was, *of) = (done, total);
                        }
                    }),
                    Ok(Err(_)) => {}
                    Err(result) => {
                        let result = result.unwrap_or_else(|_| Err("The download stopped.".into()));
                        cx.update(|cx| {
                            let updater = cx.global_mut::<Updater>();
                            let restart = updater.restart_when_ready;
                            match result {
                                Ok(()) => {
                                    updater.stage = Stage::Ready(release);
                                    if restart {
                                        Self::restart(cx);
                                    }
                                }
                                Err(error) => {
                                    updater.notice = Some((format!("Couldn't download the update: {error}"), true));
                                    updater.stage = Stage::Failed { release: Some(release), error };
                                    updater.restart_when_ready = false;
                                }
                            }
                        });
                        break;
                    }
                }
            }
        });
        cx.global_mut::<Updater>()._task = Some(task);
    }

    /// Moves the staged update over the AppImage and starts it in this
    /// one's place.
    pub fn restart(cx: &mut App) {
        let Stage::Ready(release) = &cx.global::<Updater>().stage else { return };
        let Some(target) = target() else { return };
        if let Err(error) = install(&target, &release.version) {
            let release = release.clone();
            let updater = cx.global_mut::<Updater>();
            updater.notice = Some((format!("Couldn't install the update: {error}"), true));
            updater.stage = Stage::Failed { release: Some(release), error };
            updater.restart_when_ready = false;
            return;
        }
        cx.global_mut::<Updater>().stage = Stage::Idle;
        cx.set_restart_path(target);
        cx.restart();
    }

    /// Updates to the newest version and restarts into it, all in one, as a
    /// client asks of the host it's connected to. Returns why it can't.
    pub fn update_for_remote(cx: &mut App) -> Result<(), String> {
        if !Self::can_install() {
            return Err("This device's app isn't an AppImage, so it can't update itself.".into());
        }
        cx.global_mut::<Updater>().restart_when_ready = true;
        match cx.global::<Updater>().stage {
            Stage::Ready(_) => Self::restart(cx),
            Stage::Checking | Stage::Downloading { .. } => {}
            _ => Self::check(false, cx),
        }
        Ok(())
    }

    /// The next step of an update a client asked for, after a check.
    fn continue_remote_update(cx: &mut App) {
        match &cx.global::<Updater>().stage {
            Stage::Available(_) | Stage::Failed { release: Some(_), .. } => Self::download(cx),
            Stage::Ready(_) => Self::restart(cx),
            _ => cx.global_mut::<Updater>().restart_when_ready = false,
        }
    }

    /// Whether an update a client asked for is on its way.
    pub fn remote_update_running(cx: &App) -> bool {
        cx.global::<Updater>().restart_when_ready
    }

    fn set_stage(stage: Stage, cx: &mut App) {
        cx.global_mut::<Updater>().stage = stage;
    }
}

fn server() -> String {
    std::env::var("VECTRUM_UPDATE_URL")
        .ok()
        .filter(|url| !url.is_empty())
        .unwrap_or_else(|| SERVER.to_string())
        .trim_end_matches('/')
        .to_string()
}

/// The AppImage this app runs from, which an update replaces. Programs
/// started from an AppImage inherit its `APPIMAGE`, so it only counts when
/// this program runs from inside the AppImage's mount (`APPDIR`).
fn target() -> Option<PathBuf> {
    let mount = PathBuf::from(std::env::var_os("APPDIR")?).canonicalize().ok()?;
    let exe = std::env::current_exe().ok()?.canonicalize().ok()?;
    if mount.parent().is_none() || !exe.starts_with(&mount) {
        return None;
    }
    let path = PathBuf::from(std::env::var_os("APPIMAGE")?).canonicalize().ok()?;
    path.is_file().then_some(path)
}

/// Where a download goes while it comes in, and where it waits for the
/// restart: hidden files beside the AppImage, so the move is atomic.
fn staged(target: &Path, version: &str) -> (PathBuf, PathBuf) {
    let name = target.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
    let dir = target.parent().unwrap_or(Path::new("/"));
    (dir.join(format!(".{name}.{version}.part")), dir.join(format!(".{name}.{version}.update")))
}

// ---- versions -----------------------------------------------------------------

/// `0.2.4` or `0.2.4-local`: the numbers, and whether it's a release (no
/// suffix), which comes after its pre-releases.
fn parse_version(version: &str) -> Option<(u64, u64, u64, bool)> {
    let (numbers, suffix) = match version.find(['-', '+']) {
        Some(at) => (&version[..at], Some(&version[at + 1..])),
        None => (version, None),
    };
    let mut parts = numbers.split('.').map(|part| part.parse::<u64>().ok());
    let version = (parts.next()??, parts.next()??, parts.next()??, suffix.is_none());
    parts.next().is_none().then_some(version)
}

/// Whether `candidate` is a later version than `current`.
pub fn is_newer(candidate: &str, current: &str) -> bool {
    match (parse_version(candidate), parse_version(current)) {
        (Some(candidate), Some(current)) => candidate > current,
        _ => false,
    }
}

// ---- the server ---------------------------------------------------------------

#[derive(Deserialize)]
struct Releases {
    releases: Vec<ApiRelease>,
}

#[derive(Deserialize)]
struct ApiRelease {
    tag: String,
    #[serde(default)]
    draft: bool,
    #[serde(default)]
    prerelease: bool,
    #[serde(default)]
    assets: Vec<ApiAsset>,
}

#[derive(Deserialize)]
struct ApiAsset {
    name: String,
}

fn appimage_name(version: &str) -> String {
    format!("Vectrum_Code-{version}-{}.AppImage", std::env::consts::ARCH)
}

fn agent() -> ureq::Agent {
    ureq::AgentBuilder::new()
        .timeout_connect(Duration::from_secs(15))
        .timeout_read(Duration::from_secs(30))
        .user_agent(concat!("vectrum-code/", env!("CARGO_PKG_VERSION")))
        .https_only(!server().starts_with("http://127.0.0.1") && !server().starts_with("http://localhost"))
        .build()
}

/// GETs a small file whole.
fn get_bytes(url: &str, limit: u64) -> Result<Vec<u8>, String> {
    let response = agent().get(url).call().map_err(|err| short_error(&err))?;
    let mut body = Vec::new();
    response.into_reader().take(limit + 1).read_to_end(&mut body).map_err(|err| err.to_string())?;
    if body.len() as u64 > limit {
        return Err(format!("{url} is larger than expected"));
    }
    Ok(body)
}

fn short_error(err: &ureq::Error) -> String {
    match err {
        ureq::Error::Status(code, _) => format!("the server answered HTTP {code}"),
        ureq::Error::Transport(transport) => transport.to_string(),
    }
}

/// The newest release for this machine that's newer than this app, with its
/// sums checked against the signature.
fn latest(server: &str) -> Result<Option<Release>, String> {
    let body = get_bytes(&format!("{server}/api/v1/repos/{REPO}/releases"), 4 * 1024 * 1024)?;
    let releases: Releases =
        serde_json::from_slice(&body).map_err(|err| format!("the server's release list is unreadable: {err}"))?;
    let has = |release: &ApiRelease, name: &str| release.assets.iter().any(|asset| asset.name == name);
    let Some(newest) = releases
        .releases
        .iter()
        .filter(|release| !release.draft && !release.prerelease && parse_version(&release.tag).is_some())
        .filter(|release| {
            has(release, &appimage_name(&release.tag)) && has(release, "SHA256SUMS") && has(release, "SHA256SUMS.sig")
        })
        .max_by(|a, b| parse_version(&a.tag).cmp(&parse_version(&b.tag)))
    else {
        return Ok(None);
    };
    if !is_newer(&newest.tag, current_version()) {
        return Ok(None);
    }
    let version = newest.tag.clone();
    let download = |name: &str| format!("{server}/{REPO}/releases/download/{version}/{name}");
    let sums = get_bytes(&download("SHA256SUMS"), 64 * 1024)?;
    let signature = get_bytes(&download("SHA256SUMS.sig"), 1024)?;
    verify_signature(&sums, &signature)?;
    let sums = parse_sums(&String::from_utf8_lossy(&sums));
    let appimage_sha256 = sums
        .iter()
        .find(|(_, name)| *name == appimage_name(&version))
        .map(|(hash, _)| hash.clone())
        .ok_or_else(|| format!("the signed sums of {version} don't list its AppImage"))?;
    // The notes are only shown, but they come from the same signed list.
    let notes = match sums.iter().find(|(_, name)| name == "CHANGES.md") {
        Some((hash, _)) => get_bytes(&download("CHANGES.md"), 256 * 1024)
            .ok()
            .filter(|body| sha256(body) == *hash)
            .map(|body| parse_notes(&String::from_utf8_lossy(&body)))
            .unwrap_or_default(),
        None => Vec::new(),
    };
    Ok(Some(Release {
        page: format!("{server}/{REPO}/releases/tag/{version}"),
        appimage_url: download(&appimage_name(&version)),
        version,
        notes,
        sha256: appimage_sha256,
    }))
}

/// Checks `sums` against `signature` (64 bytes, or their base64) with each
/// release key.
fn verify_signature(sums: &[u8], signature: &[u8]) -> Result<(), String> {
    verify_with(RELEASE_KEYS, sums, signature)
}

fn verify_with(keys: &[&str], sums: &[u8], signature: &[u8]) -> Result<(), String> {
    use ring::signature::{ED25519, UnparsedPublicKey};
    let signature = if signature.len() == 64 {
        signature.to_vec()
    } else {
        BASE64.decode(String::from_utf8_lossy(signature).trim()).map_err(|_| "the release's signature is unreadable".to_string())?
    };
    let signed = keys.iter().filter_map(|key| BASE64.decode(key).ok()).any(|key| {
        UnparsedPublicKey::new(&ED25519, key).verify(sums, &signature).is_ok()
    });
    if signed { Ok(()) } else { Err("the release isn't signed with Vectrum Code's release key".into()) }
}

/// `sha256sum` output: the hash and the file name on each line.
fn parse_sums(text: &str) -> Vec<(Vec<u8>, String)> {
    text.lines()
        .filter_map(|line| {
            let (hash, name) = line.split_once(char::is_whitespace)?;
            let name = name.trim_start().trim_start_matches('*');
            let hash = decode_hex(hash)?;
            (hash.len() == 32 && !name.is_empty()).then(|| (hash, name.to_string()))
        })
        .collect()
}

/// The release notes' list items, without their bullets.
fn parse_notes(text: &str) -> Vec<String> {
    text.lines()
        .filter_map(|line| line.trim().strip_prefix("- ").or_else(|| line.trim().strip_prefix("* ")))
        .map(|line| line.trim().to_string())
        .filter(|line| !line.is_empty())
        .collect()
}

fn decode_hex(text: &str) -> Option<Vec<u8>> {
    if !text.len().is_multiple_of(2) {
        return None;
    }
    (0..text.len()).step_by(2).map(|at| u8::from_str_radix(text.get(at..at + 2)?, 16).ok()).collect()
}

fn sha256(bytes: &[u8]) -> Vec<u8> {
    ring::digest::digest(&ring::digest::SHA256, bytes).as_ref().to_vec()
}

// ---- downloading and installing -------------------------------------------------

/// Downloads the release's AppImage beside `target`, checks it, and leaves
/// it staged for [`install`].
fn fetch_and_stage(
    release: &Release,
    target: &Path,
    progress: impl Fn(u64, u64),
) -> Result<(), String> {
    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};

    let (part, ready) = staged(target, &release.version);
    forget_staged(target);
    let response = agent().get(&release.appimage_url).call().map_err(|err| short_error(&err))?;
    let total = response.header("Content-Length").and_then(|length| length.parse::<u64>().ok()).unwrap_or(0);
    if total > MAX_SIZE {
        return Err("the download is larger than an update can be".into());
    }
    // Never write through whatever already sits at that name.
    let _ = fs::remove_file(&part);
    let mut file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&part)
        .map_err(|err| format!("can't write beside {}: {err}", target.display()))?;
    let result = (|| {
        let mut reader = response.into_reader().take(MAX_SIZE + 1);
        let mut hash = ring::digest::Context::new(&ring::digest::SHA256);
        let mut buffer = vec![0; 64 * 1024];
        let mut done = 0u64;
        let mut head: Vec<u8> = Vec::with_capacity(16);
        loop {
            let read = reader.read(&mut buffer).map_err(|err| err.to_string())?;
            if read == 0 {
                break;
            }
            let chunk = &buffer[..read];
            if head.len() < 16 {
                head.extend(chunk.iter().take(16 - head.len()));
            }
            hash.update(chunk);
            file.write_all(chunk).map_err(|err| err.to_string())?;
            done += read as u64;
            if done > MAX_SIZE {
                return Err("the download is larger than an update can be".into());
            }
            progress(done, total.max(done));
        }
        if total != 0 && done != total {
            return Err("the download ended early".into());
        }
        if hash.finish().as_ref() != release.sha256.as_slice() {
            return Err("the download doesn't match the release's signed checksum".into());
        }
        // An ELF file with the AppImage type 2 mark.
        if head.len() < 11 || &head[..4] != b"\x7fELF" || &head[8..11] != b"AI\x02" {
            return Err("the download isn't an AppImage".into());
        }
        file.sync_all().map_err(|err| err.to_string())?;
        fs::set_permissions(&part, fs::Permissions::from_mode(0o755)).map_err(|err| err.to_string())?;
        fs::rename(&part, &ready).map_err(|err| err.to_string())
    })();
    if result.is_err() {
        let _ = fs::remove_file(&part);
    }
    result
}

/// Removes updates staged for `target` earlier, as when a newer one came
/// out before the restart.
fn forget_staged(target: &Path) {
    let (Some(dir), Some(name)) = (target.parent(), target.file_name()) else { return };
    let prefix = format!(".{}.", name.to_string_lossy());
    for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
        let file = entry.file_name().to_string_lossy().into_owned();
        if file.starts_with(&prefix) && (file.ends_with(".update") || file.ends_with(".part")) {
            let _ = fs::remove_file(entry.path());
        }
    }
}

/// Moves a staged update over the AppImage. The running app keeps its own
/// copy open, so this is safe while it runs.
fn install(target: &Path, version: &str) -> Result<(), String> {
    let (_, ready) = staged(target, version);
    if !ready.is_file() {
        return Err("the downloaded update is gone; download it again".into());
    }
    fs::rename(&ready, target).map_err(|err| format!("can't replace {}: {err}", target.display()))
}

#[cfg(test)]
mod tests {
    // Not a glob: GPUI exports its own `test` attribute.
    use super::{BASE64, RELEASE_KEYS, is_newer, parse_notes, parse_sums, verify_with};
    use base64::Engine as _;

    #[test]
    fn versions() {
        assert!(is_newer("0.2.5", "0.2.4"));
        assert!(is_newer("0.10.0", "0.9.9"));
        assert!(is_newer("0.2.4", "0.2.4-local"));
        assert!(!is_newer("0.2.4", "0.2.4"));
        assert!(!is_newer("0.2.3", "0.2.4"));
        assert!(!is_newer("0.2.4-local", "0.2.4"));
        assert!(!is_newer("latest", "0.2.4"));
        assert!(!is_newer("0.2.4.1", "0.2.4"));
    }

    #[test]
    fn sums_and_notes() {
        let sums = parse_sums(
            "94fcdba3843199f5abc70194e39868edff9a083c61b83e1cc931adcd72047a4e  Vectrum_Code-0.2.4-x86_64.AppImage\n\
             3f146a7859fa09896e21f0f8612a54eeb0ab35fdd3d75e90b2a66e68e3245ad5 *vectrum-code-0.2.4-x86_64\n\
             nothex  CHANGES.md\n",
        );
        assert_eq!(sums.len(), 2);
        assert_eq!(sums[0].1, "Vectrum_Code-0.2.4-x86_64.AppImage");
        assert_eq!(sums[1].1, "vectrum-code-0.2.4-x86_64");
        assert_eq!(sums[0].0[0], 0x94);
        assert_eq!(parse_notes("# 0.2.5\n\n- One\n* Two\nplain\n-  \n"), vec!["One", "Two"]);
    }

    #[test]
    fn signatures() {
        use ring::signature::{Ed25519KeyPair, KeyPair as _};
        let rng = ring::rand::SystemRandom::new();
        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
        let key = BASE64.encode(pair.public_key().as_ref());
        let sums = b"abc  file\n";
        let signature = pair.sign(sums);
        assert!(verify_with(&[&key], sums, signature.as_ref()).is_ok());
        assert!(verify_with(&[&key], sums, BASE64.encode(signature.as_ref()).as_bytes()).is_ok());
        assert!(verify_with(&[&key], b"abd  file\n", signature.as_ref()).is_err());
        assert!(verify_with(RELEASE_KEYS, sums, signature.as_ref()).is_err());
    }
}