Skip to content

Stream a release asset to disk

Pick the asset

A Release carries a Vec<ReleaseAsset>. Nothing selects one for you — matching a filename to the running platform is your tool's decision, because only your tool knows its naming convention.

let release = provider.latest_release().await?;

let want = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
let asset = release
    .assets
    .iter()
    .find(|a| a.name.contains(&want))
    .ok_or_else(|| anyhow::anyhow!(
        "no asset matching `{want}` in release {}; available: {:?}",
        release.tag,
        release.assets.iter().map(|a| &a.name).collect::<Vec<_>>()
    ))?;

ReleaseAsset::name is the filename, not a URL. download_url is the fully-qualified URL and is the field download_asset uses.

Stream it

download_asset returns an AsyncRead and the content length the host reported. Copy it straight into the destination:

use tokio::io::AsyncWriteExt as _;

let (mut reader, reported_len) = provider.download_asset(asset).await?;

let mut file = tokio::fs::File::create("/tmp/download.partial").await?;
let written = tokio::io::copy(&mut reader, &mut file).await?;
file.flush().await?;

The body is streamed, not buffered — a 200 MB asset does not become a 200 MB Vec<u8>.

Do not trust the reported length

reported_len is the Content-Length header, or 0 when the header is absent. Chunked responses report 0, and even when present the value is the server's claim.

Use it to drive a progress bar and to sanity-check afterwards. Do not use it to size an allocation, and do not treat 0 as "empty":

if reported_len != 0 && written != reported_len {
    anyhow::bail!("truncated download: expected {reported_len} bytes, got {written}");
}

Enforce your own ceiling if the asset comes from anywhere you do not control — tokio::io::copy will happily write until the connection ends.

Download to a temporary path, then rename

download_asset has no resume and no integrity check. A connection dropped halfway leaves a partial file. Write to a scratch path and rename only after the copy returns:

tokio::fs::rename("/tmp/download.partial", "/tmp/download.tar.gz").await?;

If the release publishes checksums as a separate asset, fetch and verify that before the rename. Nothing in this crate does it for you.

Handle the failures that occur

use rtb_forge::ProviderError;

match provider.download_asset(asset).await {
    Err(ProviderError::NotFound { what }) => {
        // The asset URL 404'd. Common when the release metadata is
        // cached and the asset was replaced, or on a direct source
        // where the tag was never verified.
    }
    Err(ProviderError::Unauthorized { host }) => {
        // 401. The token is missing, expired, or wrong for `host`.
    }
    Err(ProviderError::RateLimited { host, retry_after }) => {
        // Back off. `retry_after` is None when the host did not say.
        let wait = retry_after.unwrap_or(std::time::Duration::from_secs(60));
    }
    Err(ProviderError::Transport(msg)) => {
        // Network failure, timeout — or a status the mapping does not
        // recognise, in which case `msg` starts "unexpected status".
        // A plain 403 from an under-scoped token arrives here.
    }
    Err(other) => return Err(other.into()),
    Ok((reader, len)) => { /* … */ }
}

The variant list is #[non_exhaustive], so keep the _ arm.

Authenticate the download

Whether the download carries credentials is decided when the provider is built, not per call — the factory's Option<SecretString> is reused for every request including asset downloads. The right header for the backend is applied automatically: Authorization: Bearer for GitHub and Direct, PRIVATE-TOKEN for GitLab, Authorization: token for Gitea and Codeberg, HTTP Basic for Bitbucket.

Bitbucket needs username in config as well as a token. With one but not the other the request is sent anonymously and a private repository answers 404.

Know what each backend can and cannot give you

  • Bitbucket pairs a tag to repository Downloads by filename substring. Only the first 100 downloads are considered, matching is not version-aware (tag v1.2 matches tool-1.2.10.tar.gz), and a download with no self link gets an empty download_url that fails as a transport error.
  • GitLab asset size is always 0 and content_type carries GitLab's link_type, not a MIME type.
  • Direct synthesises exactly one asset per release from the URL template, with size 0. There is no asset list to search.

Full detail in Release-provider methods and backend behaviour.