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 |