OXYGENHUB

Serve the releases' .debs as a signed apt repository at /apt ea85398a51bc

[apt] repos names the repositories (public only) whose release .deb
assets are indexed: control read from the package (control.tar, .gz,
.xz, .zst), Packages and Packages.gz per architecture, Release with
MD5 and SHA256, InRelease and Release.gpg signed by gpg with the key
in data/apt-gnupg, the pool serving the assets' bytes. Rebuilt when
the assets change. Tests, including gpgv verifying the signatures.

refs #85
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 19:02 UTC
Signature
✔ signed by Andy McCall
Hash
ea85398a51bced71a3258af750efa953d4dc181dcd1d0ef11cc7758ef548ef9a
Depends on
18e381ea88c2 19bb31ff1f5c 3f4b0ea6eabf 42fa95540468 43e8ca33bc5d 749d9a5fcd9f 8e96499f2705 b5668108487f d23464c0ac60
Depended on by
c8684d44d549 5d90e0e045e2 e71583501b1d b210727578e8 1f78655a0466 e71846aa07b9 72cde3da4176 d7ce41acf22c 4f37afcc3583
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 2 tree operations, 9 files

Browse the tree at this change

added crates/hub/src/apt.rs +419 −0
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.
44 +
fn parsed() -> &'static Mutex<HashMap<String, Entry>> {
45 +
    static P: OnceLock<Mutex<HashMap<String, Entry>>> = OnceLock::new();
46 +
    P.get_or_init(Default::default)
47 +
}
48 +
49 +
fn built() -> &'static Mutex<Option<Arc<Index>>> {
50 +
    static B: OnceLock<Mutex<Option<Arc<Index>>>> = OnceLock::new();
51 +
    B.get_or_init(Default::default)
52 +
}
53 +
54 +
// ------------------------------------------------------------- .deb files
55 +
56 +
/// The members of an `ar` archive, by name.
57 +
fn ar_members(bytes: &[u8]) -> Result<Vec<(String, &[u8])>, String> {
58 +
    let body = bytes.strip_prefix(b"!<arch>\n").ok_or("not an ar archive")?;
59 +
    let mut out = Vec::new();
60 +
    let mut at = 0;
61 +
    while at + 60 <= body.len() {
62 +
        let head = &body[at..at + 60];
63 +
        let name = String::from_utf8_lossy(&head[..16]).trim().trim_end_matches('/').to_string();
64 +
        let size: usize = String::from_utf8_lossy(&head[48..58]).trim().parse().map_err(|_| "bad ar member size")?;
65 +
        let start = at + 60;
66 +
        let end = start.checked_add(size).filter(|e| *e <= body.len()).ok_or("truncated ar archive")?;
67 +
        out.push((name, &body[start..end]));
68 +
        at = end + (size & 1);
69 +
    }
70 +
    Ok(out)
71 +
}
72 +
73 +
/// The `control` file of a `.deb`.
74 +
pub fn deb_control(bytes: &[u8]) -> Result<String, String> {
75 +
    let members = ar_members(bytes)?;
76 +
    let (name, data) = members.into_iter().find(|(n, _)| n.starts_with("control.tar")).ok_or("no control.tar member")?;
77 +
    let mut tar_bytes = Vec::new();
78 +
    match name.as_str() {
79 +
        "control.tar" => tar_bytes.extend_from_slice(data),
80 +
        "control.tar.gz" => {
81 +
            flate2::read::GzDecoder::new(data).read_to_end(&mut tar_bytes).map_err(|e| e.to_string())?;
82 +
        }
83 +
        "control.tar.xz" => lzma_rs::xz_decompress(&mut std::io::BufReader::new(data), &mut tar_bytes).map_err(|e| e.to_string())?,
84 +
        "control.tar.zst" => tar_bytes = zstd::decode_all(data).map_err(|e| e.to_string())?,
85 +
        other => return Err(format!("{other}: a compression this hub does not read")),
86 +
    }
87 +
    let mut archive = tar::Archive::new(tar_bytes.as_slice());
88 +
    for entry in archive.entries().map_err(|e| e.to_string())? {
89 +
        let mut entry = entry.map_err(|e| e.to_string())?;
90 +
        let path = entry.path().map_err(|e| e.to_string())?.to_string_lossy().to_string();
91 +
        if path.trim_start_matches("./") == "control" {
92 +
            let mut text = String::new();
93 +
            entry.read_to_string(&mut text).map_err(|e| e.to_string())?;
94 +
            return Ok(text);
95 +
        }
96 +
    }
97 +
    Err("no control file".into())
98 +
}
99 +
100 +
/// A control paragraph's field.
101 +
fn field<'a>(control: &'a str, name: &str) -> Option<&'a str> {
102 +
    control.lines().find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(':')).map(str::trim))
103 +
}
104 +
105 +
fn entry_of(file: &str, bytes: &[u8]) -> Result<Entry, String> {
106 +
    let control = deb_control(bytes)?;
107 +
    let package = field(&control, "Package").ok_or("no Package field")?.to_string();
108 +
    let architecture = field(&control, "Architecture").ok_or("no Architecture field")?.to_string();
109 +
    field(&control, "Version").ok_or("no Version field")?;
110 +
    Ok(Entry {
111 +
        control: control.trim_end().to_string(),
112 +
        package,
113 +
        architecture,
114 +
        file: file.to_string(),
115 +
        size: bytes.len() as u64,
116 +
        md5: hex::encode(md5::Md5::digest(bytes)),
117 +
        sha256: hex::encode(sha2::Sha256::digest(bytes)),
118 +
    })
119 +
}
120 +
121 +
// ------------------------------------------------------------------ index
122 +
123 +
/// The `.deb` assets of the configured public repositories: (asset id,
124 +
/// name, blob hash).
125 +
async fn debs(app: &Shared) -> Result<Vec<(i64, String, String)>, sqlx::Error> {
126 +
    let mut out = Vec::new();
127 +
    for slug in &app.config.apt.repos {
128 +
        let Some((owner, name)) = slug.split_once('/') else { continue };
129 +
        let Some(row) = db::repos::get(&app.db, owner, name).await? else { continue };
130 +
        if row.visibility() != hub_api::Visibility::Public {
131 +
            tracing::warn!("apt: {slug} is not public, so its packages are not served");
132 +
            continue;
133 +
        }
134 +
        let rows: Vec<(i64, String, String)> = sqlx::query_as(
135 +
            "SELECT a.id, a.name, a.hash FROM release_assets a JOIN releases r ON r.id = a.release_id \
136 +
             WHERE r.repo_id = ? AND a.name LIKE '%.deb' ORDER BY a.id",
137 +
        )
138 +
        .bind(row.id)
139 +
        .fetch_all(&app.db)
140 +
        .await?;
141 +
        out.extend(rows);
142 +
    }
143 +
    Ok(out)
144 +
}
145 +
146 +
/// The pool path of a package: `pool/COMPONENT/p/PACKAGE/FILE`.
147 +
fn pool_path(component: &str, e: &Entry) -> String {
148 +
    let first: String = e.package.chars().take(1).collect();
149 +
    format!("pool/{component}/{first}/{}/{}", e.package, e.file)
150 +
}
151 +
152 +
/// RFC 2822 date in UTC, as Release files carry it.
153 +
fn rfc2822(secs: i64) -> String {
154 +
    let t = time::OffsetDateTime::from_unix_timestamp(secs).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
155 +
    let fmt = time::macros::format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] UTC");
156 +
    t.format(&fmt).unwrap_or_default()
157 +
}
158 +
159 +
/// Run gpg with the repository's key on `input`.
160 +
async fn gpg(app: &Shared, args: &[&str], input: &[u8]) -> Result<Vec<u8>, String> {
161 +
    use tokio::io::AsyncWriteExt;
162 +
    let home = app.config.data.join(&app.config.apt.gnupg_home);
163 +
    let mut child = tokio::process::Command::new("gpg")
164 +
        .arg("--homedir")
165 +
        .arg(&home)
166 +
        .args(["--batch", "--yes", "--quiet", "--no-tty", "--pinentry-mode", "loopback", "--passphrase", ""])
167 +
        .args(args)
168 +
        .stdin(std::process::Stdio::piped())
169 +
        .stdout(std::process::Stdio::piped())
170 +
        .stderr(std::process::Stdio::piped())
171 +
        .spawn()
172 +
        .map_err(|e| format!("gpg: {e}"))?;
173 +
    let mut stdin = child.stdin.take().expect("piped");
174 +
    stdin.write_all(input).await.map_err(|e| e.to_string())?;
175 +
    drop(stdin);
176 +
    let out = child.wait_with_output().await.map_err(|e| e.to_string())?;
177 +
    if !out.status.success() {
178 +
        return Err(format!("gpg {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
179 +
    }
180 +
    Ok(out.stdout)
181 +
}
182 +
183 +
/// The index for the assets there are now, built when they changed.
184 +
async fn index(app: &Shared) -> Result<Arc<Index>, String> {
185 +
    let assets = debs(app).await.map_err(|e| e.to_string())?;
186 +
    let key: String = assets.iter().map(|(id, _, h)| format!("{id}:{h};")).collect();
187 +
    if let Some(i) = built().lock().unwrap().as_ref().filter(|i| i.key == key) {
188 +
        return Ok(i.clone());
189 +
    }
190 +
    let cfg = &app.config.apt;
191 +
    let mut entries: Vec<Entry> = Vec::new();
192 +
    let mut pool = HashMap::new();
193 +
    for (_, name, hash) in &assets {
194 +
        let known = parsed().lock().unwrap().get(hash).cloned();
195 +
        let e = match known {
196 +
            Some(e) => Entry { file: name.clone(), ..e },
197 +
            None => {
198 +
                let bytes = tokio::fs::read(crate::web::releases::blob_path(app, hash)).await.map_err(|e| e.to_string())?;
199 +
                match entry_of(name, &bytes) {
200 +
                    Ok(e) => {
201 +
                        parsed().lock().unwrap().insert(hash.clone(), e.clone());
202 +
                        e
203 +
                    }
204 +
                    Err(why) => {
205 +
                        tracing::warn!("apt: {name} is left out: {why}");
206 +
                        continue;
207 +
                    }
208 +
                }
209 +
            }
210 +
        };
211 +
        if pool.contains_key(&e.file) {
212 +
            continue;
213 +
        }
214 +
        pool.insert(e.file.clone(), hash.clone());
215 +
        entries.push(e);
216 +
    }
217 +
    let mut files = BTreeMap::new();
218 +
    for arch in &cfg.architectures {
219 +
        let mut text = String::new();
220 +
        for e in entries.iter().filter(|e| e.architecture == *arch || e.architecture == "all") {
221 +
            text.push_str(&format!(
222 +
                "{}\nFilename: {}\nSize: {}\nMD5sum: {}\nSHA256: {}\n\n",
223 +
                e.control,
224 +
                pool_path(&cfg.component, e),
225 +
                e.size,
226 +
                e.md5,
227 +
                e.sha256
228 +
            ));
229 +
        }
230 +
        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
231 +
        std::io::Write::write_all(&mut gz, text.as_bytes()).map_err(|e| e.to_string())?;
232 +
        let gz = gz.finish().map_err(|e| e.to_string())?;
233 +
        files.insert(format!("{}/binary-{arch}/Packages", cfg.component), text.into_bytes());
234 +
        files.insert(format!("{}/binary-{arch}/Packages.gz", cfg.component), gz);
235 +
    }
236 +
    let sums = |f: &dyn Fn(&[u8]) -> String| -> String {
237 +
        files.iter().map(|(path, bytes)| format!(" {} {:>8} {path}\n", f(bytes), bytes.len())).collect()
238 +
    };
239 +
    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}",
241 +
        site = app.config.site.name,
242 +
        suite = cfg.suite,
243 +
        date = rfc2822(db::now_ms() / 1000),
244 +
        arches = cfg.architectures.join(" "),
245 +
        component = cfg.component,
246 +
        md5 = sums(&|b| hex::encode(md5::Md5::digest(b))),
247 +
        sha256 = sums(&|b| hex::encode(sha2::Sha256::digest(b))),
248 +
    );
249 +
    let in_release = gpg(app, &["--clearsign", "--digest-algo", "SHA512"], release.as_bytes()).await?;
250 +
    let release_gpg = gpg(app, &["--detach-sign", "--armor", "--digest-algo", "SHA512"], release.as_bytes()).await?;
251 +
    let i = Arc::new(Index { key, files, release, in_release, release_gpg, pool });
252 +
    *built().lock().unwrap() = Some(i.clone());
253 +
    Ok(i)
254 +
}
255 +
256 +
// ----------------------------------------------------------------- routes
257 +
258 +
fn enabled(app: &Shared) -> bool {
259 +
    !app.config.apt.repos.is_empty()
260 +
}
261 +
262 +
fn reply(ct: &str, bytes: Vec<u8>) -> Response {
263 +
    ([(header::CONTENT_TYPE, ct.to_string()), (header::CACHE_CONTROL, "no-cache".to_string())], bytes).into_response()
264 +
}
265 +
266 +
fn not_found() -> Response {
267 +
    (StatusCode::NOT_FOUND, "not found\n").into_response()
268 +
}
269 +
270 +
fn failed(why: String) -> Response {
271 +
    tracing::warn!("apt: {why}");
272 +
    (StatusCode::SERVICE_UNAVAILABLE, "the package index is not available just now\n").into_response()
273 +
}
274 +
275 +
/// `GET /apt`: how to use it.
276 +
pub async fn home(State(app): State<Shared>) -> Response {
277 +
    if !enabled(&app) {
278 +
        return not_found();
279 +
    }
280 +
    let base = app.config.base_url();
281 +
    let cfg = &app.config.apt;
282 +
    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 +
         sudo curl -fsSLo /usr/share/keyrings/oxygenhub-archive-keyring.gpg {base}/apt/oxygenhub.gpg\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",
291 +
        site = app.config.site.name,
292 +
        arches = cfg.architectures.join(", "),
293 +
        suite = cfg.suite,
294 +
        component = cfg.component,
295 +
    );
296 +
    reply("text/plain; charset=utf-8", text.into_bytes())
297 +
}
298 +
299 +
/// `GET /apt/oxygenhub.gpg` (binary keyring) and `/apt/oxygenhub.asc`.
300 +
pub async fn key(State(app): State<Shared>, Path(file): Path<String>) -> Response {
301 +
    if !enabled(&app) {
302 +
        return not_found();
303 +
    }
304 +
    let armor = match file.as_str() {
305 +
        "oxygenhub.gpg" => false,
306 +
        "oxygenhub.asc" => true,
307 +
        _ => return not_found(),
308 +
    };
309 +
    let args: &[&str] = if armor { &["--armor", "--export"] } else { &["--export"] };
310 +
    match gpg(&app, args, b"").await {
311 +
        Ok(k) if !k.is_empty() => reply(if armor { "text/plain; charset=utf-8" } else { "application/pgp-keys" }, k),
312 +
        Ok(_) => failed("the signing key is missing".into()),
313 +
        Err(e) => failed(e),
314 +
    }
315 +
}
316 +
317 +
/// `GET /apt/dists/SUITE/…`: the Release files and the Packages indexes.
318 +
pub async fn dists(State(app): State<Shared>, Path((suite, rest)): Path<(String, String)>) -> Response {
319 +
    if !enabled(&app) || suite != app.config.apt.suite {
320 +
        return not_found();
321 +
    }
322 +
    let i = match index(&app).await {
323 +
        Ok(i) => i,
324 +
        Err(e) => return failed(e),
325 +
    };
326 +
    match rest.as_str() {
327 +
        "InRelease" => reply("text/plain; charset=utf-8", i.in_release.clone()),
328 +
        "Release" => reply("text/plain; charset=utf-8", i.release.clone().into_bytes()),
329 +
        "Release.gpg" => reply("application/pgp-signature", i.release_gpg.clone()),
330 +
        path => match i.files.get(path) {
331 +
            Some(b) if path.ends_with(".gz") => reply("application/gzip", b.clone()),
332 +
            Some(b) => reply("text/plain; charset=utf-8", b.clone()),
333 +
            None => not_found(),
334 +
        },
335 +
    }
336 +
}
337 +
338 +
/// `GET /apt/pool/…/FILE`: a package's bytes.
339 +
pub async fn pool(State(app): State<Shared>, Path(path): Path<String>) -> Response {
340 +
    if !enabled(&app) {
341 +
        return not_found();
342 +
    }
343 +
    let i = match index(&app).await {
344 +
        Ok(i) => i,
345 +
        Err(e) => return failed(e),
346 +
    };
347 +
    let file = path.rsplit('/').next().unwrap_or("");
348 +
    let Some(hash) = i.pool.get(file) else { return not_found() };
349 +
    match tokio::fs::read(crate::web::releases::blob_path(&app, hash)).await {
350 +
        Ok(bytes) => (
351 +
            [
352 +
                (header::CONTENT_TYPE, "application/vnd.debian.binary-package".to_string()),
353 +
                (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
354 +
            ],
355 +
            bytes,
356 +
        )
357 +
            .into_response(),
358 +
        Err(_) => not_found(),
359 +
    }
360 +
}
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.
368 +
    pub fn deb(control: &str, member: &str) -> Vec<u8> {
369 +
        let mut tar_bytes = Vec::new();
370 +
        {
371 +
            let mut t = tar::Builder::new(&mut tar_bytes);
372 +
            let mut h = tar::Header::new_gnu();
373 +
            h.set_size(control.len() as u64);
374 +
            h.set_mode(0o644);
375 +
            h.set_cksum();
376 +
            t.append_data(&mut h, "./control", control.as_bytes()).unwrap();
377 +
            t.finish().unwrap();
378 +
        }
379 +
        let packed = match member {
380 +
            "control.tar.gz" => {
381 +
                let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
382 +
                std::io::Write::write_all(&mut gz, &tar_bytes).unwrap();
383 +
                gz.finish().unwrap()
384 +
            }
385 +
            "control.tar.xz" => {
386 +
                let mut out = Vec::new();
387 +
                lzma_rs::xz_compress(&mut tar_bytes.as_slice(), &mut out).unwrap();
388 +
                out
389 +
            }
390 +
            "control.tar.zst" => zstd::encode_all(tar_bytes.as_slice(), 3).unwrap(),
391 +
            _ => tar_bytes,
392 +
        };
393 +
        let mut out = b"!<arch>\n".to_vec();
394 +
        for (name, data) in [("debian-binary", b"2.0\n".to_vec()), (member, packed), ("data.tar.gz", vec![0u8; 3])] {
395 +
            out.extend_from_slice(format!("{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n", name, 0, 0, 0, 100644, data.len()).as_bytes());
396 +
            out.extend_from_slice(&data);
397 +
            if data.len() % 2 == 1 {
398 +
                out.push(b'\n');
399 +
            }
400 +
        }
401 +
        out
402 +
    }
403 +
404 +
    #[test]
405 +
    fn control_is_read_from_every_compression() {
406 +
        let control = "Package: oxy\nVersion: 0.3.0\nArchitecture: amd64\nDescription: test\n";
407 +
        for m in ["control.tar", "control.tar.gz", "control.tar.xz", "control.tar.zst"] {
408 +
            let e = entry_of("oxy_0.3.0_amd64.deb", &deb(control, m)).unwrap();
409 +
            assert_eq!((e.package.as_str(), e.architecture.as_str()), ("oxy", "amd64"), "{m}");
410 +
            assert!(e.control.contains("Version: 0.3.0"));
411 +
        }
412 +
        assert!(deb_control(b"not a deb").is_err());
413 +
        assert_eq!(
414 +
            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 +
        );
417 +
        assert_eq!(rfc2822(0), "Thu, 01 Jan 1970 00:00:00 UTC");
418 +
    }
419 +
}
added crates/hub/tests/apt.rs +173 −0
1 +
//! The apt repository (oxygenhub#85): a release's `.deb` assets indexed,
2 +
//! signed and served at `/apt`, as apt reads them.
3 +
4 +
use axum::body::Body;
5 +
use axum::http::{Request, StatusCode};
6 +
use http_body_util::BodyExt;
7 +
use oxygenhub::config::Config;
8 +
use oxygenhub::db;
9 +
use oxygenhub::repos::RepoName;
10 +
use oxygenhub::Shared;
11 +
use std::io::Read;
12 +
use std::path::Path;
13 +
use std::process::Command;
14 +
use tower::ServiceExt;
15 +
16 +
async fn get(app: &Shared, path: &str) -> (StatusCode, Vec<u8>) {
17 +
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
18 +
    let status = res.status();
19 +
    (status, res.into_body().collect().await.unwrap().to_bytes().to_vec())
20 +
}
21 +
22 +
/// A minimal `.deb` with this control file (gzip control archive).
23 +
fn deb(control: &str) -> Vec<u8> {
24 +
    let mut tar_bytes = Vec::new();
25 +
    {
26 +
        let mut t = tar::Builder::new(&mut tar_bytes);
27 +
        let mut h = tar::Header::new_gnu();
28 +
        h.set_size(control.len() as u64);
29 +
        h.set_mode(0o644);
30 +
        h.set_cksum();
31 +
        t.append_data(&mut h, "./control", control.as_bytes()).unwrap();
32 +
        t.finish().unwrap();
33 +
    }
34 +
    let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
35 +
    std::io::Write::write_all(&mut gz, &tar_bytes).unwrap();
36 +
    let packed = gz.finish().unwrap();
37 +
    let mut out = b"!<arch>\n".to_vec();
38 +
    for (name, data) in [("debian-binary", b"2.0\n".to_vec()), ("control.tar.gz", packed), ("data.tar.gz", vec![1u8, 2, 3])] {
39 +
        out.extend_from_slice(format!("{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n", name, 0, 0, 0, 100644, data.len()).as_bytes());
40 +
        out.extend_from_slice(&data);
41 +
        if data.len() % 2 == 1 {
42 +
            out.push(b'\n');
43 +
        }
44 +
    }
45 +
    out
46 +
}
47 +
48 +
fn have_gpg() -> bool {
49 +
    Command::new("gpg").arg("--version").output().is_ok_and(|o| o.status.success())
50 +
}
51 +
52 +
fn gpg(home: &Path, args: &[&str]) -> std::process::Output {
53 +
    Command::new("gpg")
54 +
        .arg("--homedir")
55 +
        .arg(home)
56 +
        .args(["--batch", "--quiet", "--pinentry-mode", "loopback", "--passphrase", ""])
57 +
        .args(args)
58 +
        .output()
59 +
        .unwrap()
60 +
}
61 +
62 +
#[tokio::test(flavor = "multi_thread")]
63 +
async fn releases_debs_are_an_apt_repository() {
64 +
    if !have_gpg() {
65 +
        eprintln!("gpg is not installed: the apt repository is not tested here");
66 +
        return;
67 +
    }
68 +
    let dir = tempfile::tempdir().unwrap();
69 +
    let mut config = Config::load(dir.path()).unwrap();
70 +
    config.apt.repos = vec!["andy/oxy".into(), "andy/hidden".into()];
71 +
    // The signing key, in the hub's data as on Earth.
72 +
    let home = dir.path().join("apt-gnupg");
73 +
    std::fs::create_dir_all(&home).unwrap();
74 +
    std::fs::set_permissions(&home, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap();
75 +
    let made = gpg(&home, &["--quick-gen-key", "Test archive key <[email protected]>", "ed25519", "sign", "never"]);
76 +
    assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
77 +
    let app = oxygenhub::build(config).await.unwrap();
78 +
79 +
    // Off when no repository is configured is checked by the default
80 +
    // config elsewhere; here, a public repository with a release, and a
81 +
    // private one whose packages must not leak.
82 +
    db::accounts::create_account(&app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
83 +
    for (name, vis) in [("oxy", hub_api::Visibility::Public), ("hidden", hub_api::Visibility::Private)] {
84 +
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
85 +
        db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
86 +
    }
87 +
    let control = |pkg: &str, v: &str, arch: &str| {
88 +
        format!("Package: {pkg}\nVersion: {v}\nArchitecture: {arch}\nMaintainer: Andy <[email protected]>\nDescription: {pkg} for tests\n")
89 +
    };
90 +
    let oxy = db::repos::get(&app.db, "andy", "oxy").await.unwrap().unwrap();
91 +
    let rel = db::releases::create(&app.db, oxy.id, "v0.3.0", "0.3.0", "", true, None).await.unwrap();
92 +
    let amd = deb(&control("oxy", "0.3.0", "amd64"));
93 +
    oxygenhub::web::releases::store_asset(&app, rel.id, "oxy_0.3.0_amd64.deb", "", &amd).await.unwrap();
94 +
    oxygenhub::web::releases::store_asset(&app, rel.id, "oxy_0.3.0_riscv64.deb", "", &deb(&control("oxy", "0.3.0", "riscv64")))
95 +
        .await
96 +
        .unwrap();
97 +
    oxygenhub::web::releases::store_asset(&app, rel.id, "oxy-0.3.0-linux-x86_64.tar.gz", "", b"not a deb").await.unwrap();
98 +
    let hidden = db::repos::get(&app.db, "andy", "hidden").await.unwrap().unwrap();
99 +
    let hr = db::releases::create(&app.db, hidden.id, "v1", "1", "", false, None).await.unwrap();
100 +
    oxygenhub::web::releases::store_asset(&app, hr.id, "secret_1_amd64.deb", "", &deb(&control("secret", "1", "amd64"))).await.unwrap();
101 +
102 +
    let (st, how) = get(&app, "/apt").await;
103 +
    assert_eq!(st, StatusCode::OK);
104 +
    assert!(String::from_utf8_lossy(&how).contains("sources.list.d/oxygenhub.sources"));
105 +
106 +
    // The index lists both architectures' packages, not the tarball, not
107 +
    // the private repository's.
108 +
    let (st, packages) = get(&app, "/apt/dists/stable/main/binary-amd64/Packages").await;
109 +
    assert_eq!(st, StatusCode::OK);
110 +
    let packages = String::from_utf8(packages).unwrap();
111 +
    assert!(packages.contains("Package: oxy\nVersion: 0.3.0\nArchitecture: amd64"), "{packages}");
112 +
    assert!(packages.contains("Filename: pool/main/o/oxy/oxy_0.3.0_amd64.deb"), "{packages}");
113 +
    assert!(packages.contains(&format!("Size: {}", amd.len())));
114 +
    assert!(!packages.contains("secret"), "a private repository's packages are not served");
115 +
    let (_, riscv) = get(&app, "/apt/dists/stable/main/binary-riscv64/Packages").await;
116 +
    assert!(String::from_utf8_lossy(&riscv).contains("oxy_0.3.0_riscv64.deb"));
117 +
    let (st, gz) = get(&app, "/apt/dists/stable/main/binary-amd64/Packages.gz").await;
118 +
    assert_eq!(st, StatusCode::OK);
119 +
    let mut unzipped = String::new();
120 +
    flate2::read::GzDecoder::new(gz.as_slice()).read_to_string(&mut unzipped).unwrap();
121 +
    assert_eq!(unzipped, packages);
122 +
123 +
    // Release names every index with its hash; InRelease and Release.gpg
124 +
    // verify against the published key.
125 +
    let (_, release) = get(&app, "/apt/dists/stable/Release").await;
126 +
    let release = String::from_utf8(release).unwrap();
127 +
    use sha2::Digest;
128 +
    let sum = hex::encode(sha2::Sha256::digest(packages.as_bytes()));
129 +
    assert!(release.contains(&format!("{sum} {:>8} main/binary-amd64/Packages\n", packages.len())), "{release}");
130 +
    assert!(release.contains("Architectures: amd64 arm64 riscv64"));
131 +
    let (st, key) = get(&app, "/apt/oxygenhub.gpg").await;
132 +
    assert_eq!(st, StatusCode::OK);
133 +
    let keyring = dir.path().join("keyring.gpg");
134 +
    std::fs::write(&keyring, &key).unwrap();
135 +
    let (_, in_release) = get(&app, "/apt/dists/stable/InRelease").await;
136 +
    assert!(String::from_utf8_lossy(&in_release).starts_with("-----BEGIN PGP SIGNED MESSAGE-----"));
137 +
    let signed = dir.path().join("InRelease");
138 +
    std::fs::write(&signed, &in_release).unwrap();
139 +
    let v = Command::new("gpgv").arg("--keyring").arg(&keyring).arg(&signed).output().unwrap();
140 +
    assert!(v.status.success(), "InRelease verifies: {}", String::from_utf8_lossy(&v.stderr));
141 +
    let (_, detached) = get(&app, "/apt/dists/stable/Release.gpg").await;
142 +
    std::fs::write(dir.path().join("Release.gpg"), &detached).unwrap();
143 +
    std::fs::write(dir.path().join("Release"), &release).unwrap();
144 +
    let v = Command::new("gpgv")
145 +
        .arg("--keyring")
146 +
        .arg(&keyring)
147 +
        .arg(dir.path().join("Release.gpg"))
148 +
        .arg(dir.path().join("Release"))
149 +
        .output()
150 +
        .unwrap();
151 +
    assert!(v.status.success(), "Release.gpg verifies: {}", String::from_utf8_lossy(&v.stderr));
152 +
153 +
    // The pool serves the package's own bytes; nothing else.
154 +
    let (st, bytes) = get(&app, "/apt/pool/main/o/oxy/oxy_0.3.0_amd64.deb").await;
155 +
    assert_eq!((st, bytes.len()), (StatusCode::OK, amd.len()));
156 +
    assert_eq!(get(&app, "/apt/pool/main/s/secret/secret_1_amd64.deb").await.0, StatusCode::NOT_FOUND);
157 +
    assert_eq!(get(&app, "/apt/dists/unstable/Release").await.0, StatusCode::NOT_FOUND);
158 +
159 +
    // A new release is in the index at once.
160 +
    let rel2 = db::releases::create(&app.db, oxy.id, "v0.3.1", "0.3.1", "", true, None).await.unwrap();
161 +
    oxygenhub::web::releases::store_asset(&app, rel2.id, "oxy_0.3.1_amd64.deb", "", &deb(&control("oxy", "0.3.1", "amd64"))).await.unwrap();
162 +
    let (_, packages) = get(&app, "/apt/dists/stable/main/binary-amd64/Packages").await;
163 +
    assert!(String::from_utf8_lossy(&packages).contains("Version: 0.3.1"));
164 +
    Command::new("gpgconf").arg("--homedir").arg(&home).args(["--kill", "gpg-agent"]).output().ok();
165 +
}
166 +
167 +
#[tokio::test(flavor = "multi_thread")]
168 +
async fn apt_is_off_unless_configured() {
169 +
    let dir = tempfile::tempdir().unwrap();
170 +
    let app = oxygenhub::build(Config::load(dir.path()).unwrap()).await.unwrap();
171 +
    assert_eq!(get(&app, "/apt").await.0, StatusCode::NOT_FOUND);
172 +
    assert_eq!(get(&app, "/apt/dists/stable/InRelease").await.0, StatusCode::NOT_FOUND);
173 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **An apt repository at `/apt` (#85).** The hub serves the `.deb` assets of the releases of the repositories named in `[apt] repos` (public ones only) as a signed apt repository: `dists/stable/InRelease`, `Release` and `Release.gpg`, `main/binary-{amd64,arm64,riscv64}/Packages(.gz)`, and the pool. The index is rebuilt when a release's packages change, and signed with the key in the data directory's `apt-gnupg` (public key at `/apt/oxygenhub.gpg`). `/apt` says how to add it.
8 +
7 9
- **The proposal page's sidebar matches the issue page's (#84).** Sections with headings; Reviewers and Labels open filterable menus from a gear (people with access to the repository, with avatars; labels with colour and description) that apply in one go; requested reviewers show with avatars; Landing, Checks, Approvals, Projects, Code owners and Depends on as sections. The reviewers form takes the whole list (`action=set`).
8 10
9 11
- **Device sign-in says it is done (#83).** After Approve, `/device` shows "Signed in: oxy on saturn is now signed in as you; return to it, there is nothing to copy or enter" with a link to its token, instead of the code form again, which read as if the code were needed a second time. Denying shows a matching "Denied" card.
…
41 unchanged lines
modified Cargo.lock +14 −0
…
1834 unchanged lines
1835 1835
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
1836 1836
1837 1837
[[package]]
1838 +
name = "lzma-rs"
1839 +
version = "0.3.0"
1840 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1841 +
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
1842 +
dependencies = [
1843 +
 "byteorder",
1844 +
 "crc",
1845 +
]
1846 +
1847 +
[[package]]
1838 1848
name = "maplit"
1839 1849
version = "1.0.2"
1840 1850
source = "registry+https://github.com/rust-lang/crates.io-index"
…
369 unchanged lines
2210 2220
 "base32",
2211 2221
 "blake3",
2212 2222
 "clap",
2223 +
 "flate2",
2213 2224
 "flow-core",
2214 2225
 "futures",
2215 2226
 "hex",
…
2 unchanged lines
2218 2229
 "hub-api",
2219 2230
 "image",
2220 2231
 "lettre",
2232 +
 "lzma-rs",
2233 +
 "md-5",
2221 2234
 "oxy-core",
2222 2235
 "oxy-server",
2223 2236
 "pulldown-cmark",
…
6 unchanged lines
2230 2243
 "serde",
2231 2244
 "serde_json",
2232 2245
 "sha1",
2246 +
 "sha2 0.10.9",
2233 2247
 "similar",
2234 2248
 "sqlx",
2235 2249
 "subtle",
…
2399 unchanged lines
modified crates/hub/Cargo.toml +5 −0
…
56 unchanged lines
57 57
blake3.workspace = true
58 58
tar.workspace = true
59 59
zstd.workspace = true
60 +
# apt repository (oxygenhub#85): .deb control archives, index hashes.
61 +
flate2 = "1"
62 +
lzma-rs = "0.3"
63 +
sha2 = "0.10"
64 +
md-5 = "0.10"
60 65
zip.workspace = true
61 66
lettre.workspace = true
62 67
regex.workspace = true
…
8 unchanged lines
modified crates/hub/src/config.rs +31 −0
…
19 unchanged lines
20 20
    pub limits: Limits,
21 21
    pub retention: Retention,
22 22
    pub flow: Flow,
23 +
    pub apt: Apt,
23 24
}
24 25
26 +
/// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these
27 +
/// public repositories' releases, signed with the key in `gnupg_home`.
28 +
#[derive(Clone, Debug, Deserialize)]
29 +
#[serde(default, deny_unknown_fields)]
30 +
pub struct Apt {
31 +
    /// `owner/name` of each repository whose release `.deb`s are served;
32 +
    /// none turns `/apt` off.
33 +
    pub repos: Vec<String>,
34 +
    /// The suite apt's `sources` names.
35 +
    pub suite: String,
36 +
    pub component: String,
37 +
    /// GnuPG home holding the signing key (relative to the data directory).
38 +
    pub gnupg_home: String,
39 +
    /// Architectures the index lists, each with its Packages file.
40 +
    pub architectures: Vec<String>,
41 +
}
42 +
43 +
impl Default for Apt {
44 +
    fn default() -> Self {
45 +
        Apt {
46 +
            repos: vec![],
47 +
            suite: "stable".into(),
48 +
            component: "main".into(),
49 +
            gnupg_home: "apt-gnupg".into(),
50 +
            architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()],
51 +
        }
52 +
    }
53 +
}
54 +
25 55
/// OxygenFlow runs (hub spec §13.2).
26 56
#[derive(Clone, Debug, Deserialize)]
27 57
#[serde(default, deny_unknown_fields)]
…
180 unchanged lines
208 238
            limits: Limits::default(),
209 239
            retention: Retention::default(),
210 240
            flow: Flow::default(),
241 +
            apt: Apt::default(),
211 242
        }
212 243
    }
213 244
}
…
123 unchanged lines
modified crates/hub/src/lib.rs +1 −0
…
3 unchanged lines
4 4
//! protocol; SQLite for hub state; repositories as bare Oxygen stores.
5 5
6 6
pub mod api;
7 +
pub mod apt;
7 8
pub mod auth;
8 9
pub mod config;
9 10
pub mod db;
…
102 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
115 unchanged lines
116 116
        .route("/{owner}/{name}/last/{r}", get(repo::last_changes))
117 117
        .route("/{owner}/{name}/changes/{r}", get(history::history))
118 118
        .route("/{owner}/{name}/change/{hash}", get(history::change))
119 +
        .route("/apt", get(crate::apt::home))
120 +
        .route("/apt/{file}", get(crate::apt::key))
121 +
        .route("/apt/dists/{suite}/{*rest}", get(crate::apt::dists))
122 +
        .route("/apt/pool/{*path}", get(crate::apt::pool))
119 123
        .route("/help", get(help::help))
120 124
        .route("/projects/{owner}", get(projects::list).post(projects::create))
121 125
        .route("/projects/{owner}/{number}", get(projects::show).post(projects::act))
…
496 unchanged lines
modified crates/hub/src/web/releases.rs +1 −1
…
324 unchanged lines
325 325
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
326 326
}
327 327
328 -
fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf {
328 +
pub(crate) fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf {
329 329
    app.config.data.join("assets").join(&hash[..2]).join(hash)
330 330
}
331 331
…
186 unchanged lines