OXYGENHUB

Coverage / crates/hub/src/apt.rs 38.6%

122 of 316 lines run, 18 of 104 functions · run #492 on proposal/340 · run missed

1
//! The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of the
2
//! configured repositories' releases, indexed and signed so that
3
//! `apt install oxy oh` and `apt upgrade` work. Nothing is stored apart:
4
//! the index is built from the releases when they change, and the pool
5
//! serves the assets' own bytes.
6
7
use crate::{db, Shared};
8
use axum::extract::{Path, State};
9
use axum::http::{header, StatusCode};
10
use axum::response::{IntoResponse, Response};
11
use sha2::Digest;
12
use std::collections::{BTreeMap, HashMap};
13
use std::io::Read;
14
use std::sync::{Arc, Mutex, OnceLock};
15
16
/// One package as the index lists it.
17
#[derive(Clone, Debug)]
18
struct Entry {
19
    /// The control paragraph, as the package has it.
20
    control: String,
21
    package: String,
22
    architecture: String,
23
    /// The asset's name, which is its pool file name.
24
    file: String,
25
    size: u64,
26
    md5: String,
27
    sha256: String,
28
}
29
30
/// The signed index, built from one set of assets.
31
struct Index {
32
    /// What it was built from: each `.deb` asset's id and hash.
33
    key: String,
34
    /// `main/binary-ARCH/Packages` and `.gz`, by their path under the suite.
35
    files: BTreeMap<String, Vec<u8>>,
36
    release: String,
37
    in_release: Vec<u8>,
38
    release_gpg: Vec<u8>,
39
    /// Pool file name to the asset's blob hash.
40
    pool: HashMap<String, String>,
41
}
42
43
/// Parsed `.deb`s by blob hash: a package is read once.
440×
fn parsed() -> &'static Mutex<HashMap<String, Entry>> {
45
    static P: OnceLock<Mutex<HashMap<String, Entry>>> = OnceLock::new();
460×
    P.get_or_init(Default::default)
470×
}
48
490×
fn built() -> &'static Mutex<Option<Arc<Index>>> {
50
    static B: OnceLock<Mutex<Option<Arc<Index>>>> = OnceLock::new();
510×
    B.get_or_init(Default::default)
520×
}
53
54
// ------------------------------------------------------------- .deb files
55
56
/// The members of an `ar` archive, by name.
576×
fn ar_members(bytes: &[u8]) -> Result<Vec<(String, &[u8])>, String> {
586×
    let body = bytes.strip_prefix(b"!<arch>\n").ok_or("not an ar archive")?;
595×
    let mut out = Vec::new();
605×
    let mut at = 0;
6120×
    while at + 60 <= body.len() {
6215×
        let head = &body[at..at + 60];
6315×
        let name = String::from_utf8_lossy(&head[..16]).trim().trim_end_matches('/').to_string();
6415×
        let size: usize = String::from_utf8_lossy(&head[48..58]).trim().parse().map_err(|_| "bad ar member size")?;
6515×
        let start = at + 60;
6615×
        let end = start.checked_add(size).filter(|e| *e <= body.len()).ok_or("truncated ar archive")?;
6715×
        out.push((name, &body[start..end]));
6815×
        at = end + (size & 1);
69
    }
705×
    Ok(out)
716×
}
72
73
/// The `control` file of a `.deb`.
746×
pub fn deb_control(bytes: &[u8]) -> Result<String, String> {
756×
    let members = ar_members(bytes)?;
7610×
    let (name, data) = members.into_iter().find(|(n, _)| n.starts_with("control.tar")).ok_or("no control.tar member")?;
775×
    let mut tar_bytes = Vec::new();
785×
    match name.as_str() {
795×
        "control.tar" => tar_bytes.extend_from_slice(data),
804×
        "control.tar.gz" => {
812×
            flate2::read::GzDecoder::new(data).read_to_end(&mut tar_bytes).map_err(|e| e.to_string())?;
82
        }
832×
        "control.tar.xz" => lzma_rs::xz_decompress(&mut std::io::BufReader::new(data), &mut tar_bytes).map_err(|e| e.to_string())?,
841×
        "control.tar.zst" => tar_bytes = zstd::decode_all(data).map_err(|e| e.to_string())?,
850×
        other => return Err(format!("{other}: a compression this hub does not read")),
86
    }
875×
    let mut archive = tar::Archive::new(tar_bytes.as_slice());
885×
    for entry in archive.entries().map_err(|e| e.to_string())? {
895×
        let mut entry = entry.map_err(|e| e.to_string())?;
905×
        let path = entry.path().map_err(|e| e.to_string())?.to_string_lossy().to_string();
915×
        if path.trim_start_matches("./") == "control" {
925×
            let mut text = String::new();
935×
            entry.read_to_string(&mut text).map_err(|e| e.to_string())?;
945×
            return Ok(text);
950×
        }
96
    }
970×
    Err("no control file".into())
986×
}
99
100
/// A control paragraph's field.
10115×
fn field<'a>(control: &'a str, name: &str) -> Option<&'a str> {
10230×
    control.lines().find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(':')).map(str::trim))
10315×
}
104
1055×
fn entry_of(file: &str, bytes: &[u8]) -> Result<Entry, String> {
1065×
    let control = deb_control(bytes)?;
1075×
    let package = field(&control, "Package").ok_or("no Package field")?.to_string();
1085×
    let architecture = field(&control, "Architecture").ok_or("no Architecture field")?.to_string();
1095×
    field(&control, "Version").ok_or("no Version field")?;
1105×
    Ok(Entry {
1115×
        control: control.trim_end().to_string(),
1125×
        package,
1135×
        architecture,
1145×
        file: file.to_string(),
1155×
        size: bytes.len() as u64,
1165×
        md5: hex::encode(md5::Md5::digest(bytes)),
1175×
        sha256: hex::encode(sha2::Sha256::digest(bytes)),
1185×
    })
1195×
}
120
121
// ------------------------------------------------------------------ index
122
123
/// The `.deb` assets of the configured public repositories: (asset id,
124
/// name, blob hash).
1250×
async fn debs(app: &Shared) -> Result<Vec<(i64, String, String)>, sqlx::Error> {
1260×
    let mut out = Vec::new();
1270×
    for slug in &app.config.apt.repos {
1280×
        let Some((owner, name)) = slug.split_once('/') else { continue };
1290×
        let Some(row) = db::repos::get(&app.db, owner, name).await? else { continue };
1300×
        if row.visibility() != hub_api::Visibility::Public {
1310×
            tracing::warn!("apt: {slug} is not public, so its packages are not served");
1320×
            continue;
1330×
        }
1340×
        let rows: Vec<(i64, String, String)> = sqlx::query_as(
1350×
            "SELECT a.id, a.name, a.hash FROM release_assets a JOIN releases r ON r.id = a.release_id \
1360×
             WHERE r.repo_id = $1 AND a.name LIKE '%.deb' ORDER BY a.id",
1370×
        )
1380×
        .bind(row.id)
1390×
        .fetch_all(&app.db)
1400×
        .await?;
1410×
        out.extend(rows);
142
    }
1430×
    Ok(out)
1440×
}
145
146
/// The pool path of a package: `pool/COMPONENT/p/PACKAGE/FILE`.
1471×
fn pool_path(component: &str, e: &Entry) -> String {
1481×
    let first: String = e.package.chars().take(1).collect();
1491×
    format!("pool/{component}/{first}/{}/{}", e.package, e.file)
1501×
}
151
152
/// RFC 2822 date in UTC, as Release files carry it.
1531×
fn rfc2822(secs: i64) -> String {
1541×
    let t = time::OffsetDateTime::from_unix_timestamp(secs).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1551×
    let fmt = time::macros::format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] UTC");
1561×
    t.format(&fmt).unwrap_or_default()
1571×
}
158
159
/// Run gpg with the repository's key on `input`.
1600×
async fn gpg(app: &Shared, args: &[&str], input: &[u8]) -> Result<Vec<u8>, String> {
161
    use tokio::io::AsyncWriteExt;
1620×
    let home = app.config.data.join(&app.config.apt.gnupg_home);
1630×
    let mut child = tokio::process::Command::new("gpg")
1640×
        .arg("--homedir")
1650×
        .arg(&home)
1660×
        .args(["--batch", "--yes", "--quiet", "--no-tty", "--pinentry-mode", "loopback", "--passphrase", ""])
1670×
        .args(args)
1680×
        .stdin(std::process::Stdio::piped())
1690×
        .stdout(std::process::Stdio::piped())
1700×
        .stderr(std::process::Stdio::piped())
1710×
        .spawn()
1720×
        .map_err(|e| format!("gpg: {e}"))?;
1730×
    let mut stdin = child.stdin.take().expect("piped");
1740×
    stdin.write_all(input).await.map_err(|e| e.to_string())?;
1750×
    drop(stdin);
1760×
    let out = child.wait_with_output().await.map_err(|e| e.to_string())?;
1770×
    if !out.status.success() {
1780×
        return Err(format!("gpg {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
1790×
    }
1800×
    Ok(out.stdout)
1810×
}
182
183
/// The index for the assets there are now, built when they changed.
1840×
async fn index(app: &Shared) -> Result<Arc<Index>, String> {
1850×
    let assets = debs(app).await.map_err(|e| e.to_string())?;
1860×
    let key: String = assets.iter().map(|(id, _, h)| format!("{id}:{h};")).collect();
1870×
    if let Some(i) = built().lock().unwrap().as_ref().filter(|i| i.key == key) {
1880×
        return Ok(i.clone());
1890×
    }
1900×
    let cfg = &app.config.apt;
1910×
    let mut entries: Vec<Entry> = Vec::new();
1920×
    let mut pool = HashMap::new();
1930×
    for (_, name, hash) in &assets {
1940×
        let known = parsed().lock().unwrap().get(hash).cloned();
1950×
        let e = match known {
1960×
            Some(e) => Entry { file: name.clone(), ..e },
197
            None => {
1980×
                let bytes = tokio::fs::read(crate::web::releases::blob_path(app, hash)).await.map_err(|e| e.to_string())?;
1990×
                match entry_of(name, &bytes) {
2000×
                    Ok(e) => {
2010×
                        parsed().lock().unwrap().insert(hash.clone(), e.clone());
2020×
                        e
203
                    }
2040×
                    Err(why) => {
2050×
                        tracing::warn!("apt: {name} is left out: {why}");
2060×
                        continue;
207
                    }
208
                }
209
            }
210
        };
2110×
        if pool.contains_key(&e.file) {
2120×
            continue;
2130×
        }
2140×
        pool.insert(e.file.clone(), hash.clone());
2150×
        entries.push(e);
216
    }
2170×
    let mut files = BTreeMap::new();
2180×
    for arch in &cfg.architectures {
2190×
        let mut text = String::new();
2200×
        for e in entries.iter().filter(|e| e.architecture == *arch || e.architecture == "all") {
2210×
            text.push_str(&format!(
2220×
                "{}\nFilename: {}\nSize: {}\nMD5sum: {}\nSHA256: {}\n\n",
2230×
                e.control,
2240×
                pool_path(&cfg.component, e),
2250×
                e.size,
2260×
                e.md5,
2270×
                e.sha256
2280×
            ));
2290×
        }
2300×
        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
2310×
        std::io::Write::write_all(&mut gz, text.as_bytes()).map_err(|e| e.to_string())?;
2320×
        let gz = gz.finish().map_err(|e| e.to_string())?;
2330×
        files.insert(format!("{}/binary-{arch}/Packages", cfg.component), text.into_bytes());
2340×
        files.insert(format!("{}/binary-{arch}/Packages.gz", cfg.component), gz);
235
    }
2360×
    let sums = |f: &dyn Fn(&[u8]) -> String| -> String {
2370×
        files.iter().map(|(path, bytes)| format!(" {} {:>8} {path}\n", f(bytes), bytes.len())).collect()
2380×
    };
2390×
    let release = format!(
240
        "Origin: {site}\nLabel: {site}\nSuite: {suite}\nCodename: {suite}\nDate: {date}\nArchitectures: {arches}\nComponents: {component}\nDescription: {site} packages (oxy and oh)\nMD5Sum:\n{md5}SHA256:\n{sha256}",
2410×
        site = app.config.site.name,
242
        suite = cfg.suite,
2430×
        date = rfc2822(db::now_ms() / 1000),
2440×
        arches = cfg.architectures.join(" "),
245
        component = cfg.component,
2460×
        md5 = sums(&|b| hex::encode(md5::Md5::digest(b))),
2470×
        sha256 = sums(&|b| hex::encode(sha2::Sha256::digest(b))),
248
    );
2490×
    let in_release = gpg(app, &["--clearsign", "--digest-algo", "SHA512"], release.as_bytes()).await?;
2500×
    let release_gpg = gpg(app, &["--detach-sign", "--armor", "--digest-algo", "SHA512"], release.as_bytes()).await?;
2510×
    let i = Arc::new(Index { key, files, release, in_release, release_gpg, pool });
2520×
    *built().lock().unwrap() = Some(i.clone());
2530×
    Ok(i)
2540×
}
255
256
// ----------------------------------------------------------------- routes
257
2582×
fn enabled(app: &Shared) -> bool {
2592×
    !app.config.apt.repos.is_empty()
2602×
}
261
2620×
fn reply(ct: &str, bytes: Vec<u8>) -> Response {
2630×
    ([(header::CONTENT_TYPE, ct.to_string()), (header::CACHE_CONTROL, "no-cache".to_string())], bytes).into_response()
2640×
}
265
2662×
fn not_found() -> Response {
2672×
    (StatusCode::NOT_FOUND, "not found\n").into_response()
2682×
}
269
2700×
fn failed(why: String) -> Response {
2710×
    tracing::warn!("apt: {why}");
2720×
    (StatusCode::SERVICE_UNAVAILABLE, "the package index is not available just now\n").into_response()
2730×
}
274
275
/// `GET /apt`: how to use it.
2761×
pub async fn home(State(app): State<Shared>) -> Response {
2771×
    if !enabled(&app) {
2781×
        return not_found();
2790×
    }
2800×
    let base = app.config.base_url();
2810×
    let cfg = &app.config.apt;
2820×
    let text = format!(
283
        "{site} apt repository: oxy and oh for Debian and Ubuntu ({arches}).\n\n\
284
         Installing a .deb from a release adds this repository by itself. To add it by hand:\n\n\
285
         curl -fsSL {base}/apt/oxygenhub.gpg | sudo tee /usr/share/keyrings/oxygenhub-archive-keyring.gpg >/dev/null\n\
286
         sudo tee /etc/apt/sources.list.d/oxygenhub.sources >/dev/null <<EOF\n\
287
         Types: deb\nURIs: {base}/apt\nSuites: {suite}\nComponents: {component}\n\
288
         Signed-By: /usr/share/keyrings/oxygenhub-archive-keyring.gpg\nEOF\n\
289
         sudo apt update && sudo apt install oxy oh\n\n\
290
         The signing key's fingerprint is in {base}/apt/oxygenhub.asc (gpg --show-keys).\n",
2910×
        site = app.config.site.name,
2920×
        arches = cfg.architectures.join(", "),
293
        suite = cfg.suite,
294
        component = cfg.component,
295
    );
2960×
    reply("text/plain; charset=utf-8", text.into_bytes())
2971×
}
298
299
/// `GET /apt/oxygenhub.gpg` (binary keyring) and `/apt/oxygenhub.asc`.
3000×
pub async fn key(State(app): State<Shared>, Path(file): Path<String>) -> Response {
3010×
    if !enabled(&app) {
3020×
        return not_found();
3030×
    }
3040×
    let armor = match file.as_str() {
3050×
        "oxygenhub.gpg" => false,
3060×
        "oxygenhub.asc" => true,
3070×
        _ => return not_found(),
308
    };
3090×
    let args: &[&str] = if armor { &["--armor", "--export"] } else { &["--export"] };
3100×
    match gpg(&app, args, b"").await {
3110×
        Ok(k) if !k.is_empty() => reply(if armor { "text/plain; charset=utf-8" } else { "application/pgp-keys" }, k),
3120×
        Ok(_) => failed("the signing key is missing".into()),
3130×
        Err(e) => failed(e),
314
    }
3150×
}
316
317
/// `GET /apt/dists/SUITE/…`: the Release files and the Packages indexes.
3181×
pub async fn dists(State(app): State<Shared>, Path((suite, rest)): Path<(String, String)>) -> Response {
3191×
    if !enabled(&app) || suite != app.config.apt.suite {
3201×
        return not_found();
3210×
    }
3220×
    let i = match index(&app).await {
3230×
        Ok(i) => i,
3240×
        Err(e) => return failed(e),
325
    };
3260×
    match rest.as_str() {
3270×
        "InRelease" => reply("text/plain; charset=utf-8", i.in_release.clone()),
3280×
        "Release" => reply("text/plain; charset=utf-8", i.release.clone().into_bytes()),
3290×
        "Release.gpg" => reply("application/pgp-signature", i.release_gpg.clone()),
3300×
        path => match i.files.get(path) {
3310×
            Some(b) if path.ends_with(".gz") => reply("application/gzip", b.clone()),
3320×
            Some(b) => reply("text/plain; charset=utf-8", b.clone()),
3330×
            None => not_found(),
334
        },
335
    }
3361×
}
337
338
/// `GET /apt/pool/…/FILE`: a package's bytes.
3390×
pub async fn pool(State(app): State<Shared>, Path(path): Path<String>) -> Response {
3400×
    if !enabled(&app) {
3410×
        return not_found();
3420×
    }
3430×
    let i = match index(&app).await {
3440×
        Ok(i) => i,
3450×
        Err(e) => return failed(e),
346
    };
3470×
    let file = path.rsplit('/').next().unwrap_or("");
3480×
    let Some(hash) = i.pool.get(file) else { return not_found() };
3490×
    match tokio::fs::read(crate::web::releases::blob_path(&app, hash)).await {
3500×
        Ok(bytes) => (
3510×
            [
3520×
                (header::CONTENT_TYPE, "application/vnd.debian.binary-package".to_string()),
3530×
                (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
3540×
            ],
3550×
            bytes,
3560×
        )
3570×
            .into_response(),
3580×
        Err(_) => not_found(),
359
    }
3600×
}
361
362
#[cfg(test)]
363
mod tests {
364
    use super::*;
365
366
    /// A minimal `.deb`: an ar archive with a control archive compressed
367
    /// as `name` says.
3685×
    pub fn deb(control: &str, member: &str) -> Vec<u8> {
3695×
        let mut tar_bytes = Vec::new();
3705×
        {
3715×
            let mut t = tar::Builder::new(&mut tar_bytes);
3725×
            let mut h = tar::Header::new_gnu();
3735×
            h.set_size(control.len() as u64);
3745×
            h.set_mode(0o644);
3755×
            h.set_cksum();
3765×
            t.append_data(&mut h, "./control", control.as_bytes()).unwrap();
3775×
            t.finish().unwrap();
3785×
        }
3795×
        let packed = match member {
3805×
            "control.tar.gz" => {
3812×
                let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
3822×
                std::io::Write::write_all(&mut gz, &tar_bytes).unwrap();
3832×
                gz.finish().unwrap()
384
            }
3853×
            "control.tar.xz" => {
3861×
                let mut out = Vec::new();
3871×
                lzma_rs::xz_compress(&mut tar_bytes.as_slice(), &mut out).unwrap();
3881×
                out
389
            }
3902×
            "control.tar.zst" => zstd::encode_all(tar_bytes.as_slice(), 3).unwrap(),
3911×
            _ => tar_bytes,
392
        };
3935×
        let mut out = b"!<arch>\n".to_vec();
39415×
        for (name, data) in [("debian-binary", b"2.0\n".to_vec()), (member, packed), ("data.tar.gz", vec![0u8; 3])] {
39515×
            out.extend_from_slice(format!("{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n", name, 0, 0, 0, 100644, data.len()).as_bytes());
39615×
            out.extend_from_slice(&data);
39715×
            if data.len() % 2 == 1 {
3987×
                out.push(b'\n');
3998×
            }
400
        }
4015×
        out
4025×
    }
403
404
    #[test]
4051×
    fn control_is_read_from_every_compression() {
4061×
        let control = "Package: oxy\nVersion: 0.3.0\nArchitecture: amd64\nDescription: test\n";
4074×
        for m in ["control.tar", "control.tar.gz", "control.tar.xz", "control.tar.zst"] {
4084×
            let e = entry_of("oxy_0.3.0_amd64.deb", &deb(control, m)).unwrap();
4094×
            assert_eq!((e.package.as_str(), e.architecture.as_str()), ("oxy", "amd64"), "{m}");
4104×
            assert!(e.control.contains("Version: 0.3.0"));
411
        }
4121×
        assert!(deb_control(b"not a deb").is_err());
4131×
        assert_eq!(
4141×
            pool_path("main", &entry_of("oxy_0.3.0_amd64.deb", &deb(control, "control.tar.gz")).unwrap()),
415
            "pool/main/o/oxy/oxy_0.3.0_amd64.deb"
416
        );
4171×
        assert_eq!(rfc2822(0), "Thu, 01 Jan 1970 00:00:00 UTC");
4181×
    }
419
}