//! Builds of the client to install without a clone: `/install.sh`, which //! fetches one, and `/releases/`, which holds them, laid out as //! `deploy/release.sh` lays them out: `latest`, the newest version, and //! per version one archive per target and its checksum. //! //! ```text //! latest //! 0.1.0/knotless-0.1.0-aarch64-apple-darwin.tar.gz //! 0.1.0/knotless-0.1.0-aarch64-apple-darwin.tar.gz.sha256 //! ``` //! //! They are files in a directory, or a repository's releases of its //! `knotless` package (docs/design/packages.md), each file read from its //! owner's PDS once, checked against its record, and kept. use axum::Router; use axum::extract::{Path as Params, State}; use axum::http::{HeaderMap, HeaderName, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use knotless::env::Ctx; use knotless::records::ReleaseRecord; use knotless::release; use knotless::remote::Remote; use knotless::url::{RepoUrl, SCHEME}; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; const SCRIPT: &str = include_str!("install.sh"); /// The package whose releases a repository's are, installed by the script. pub const PACKAGE: &str = "knotless"; /// How long a repository's list of releases is used before it is read /// again: a new release is the latest within this. pub const LISTED_FOR: Duration = Duration::from_secs(60); /// Where the builds handed out come from. #[derive(Debug, Clone, PartialEq)] pub enum Source { /// A directory laid out as `deploy/release.sh` lays one out. Dir(PathBuf), /// A repository's releases of [`PACKAGE`]. Repo(RepoUrl), } impl Source { /// A `knotless://` URL as a repository, and anything else as a /// directory. pub fn parse(value: &str) -> Result { if value.starts_with(SCHEME) { return RepoUrl::parse(value) .map(Source::Repo) .map_err(|e| e.to_string()); } Ok(Source::Dir(value.into())) } } /// The script and the releases from `source`; nothing, with none. The /// script fetches from `public_url`, or else from the host it was asked /// for. A repository's are read as `ctx`. pub fn routes(source: Option, public_url: Option, ctx: Ctx) -> Router { let Some(source) = source else { return Router::new(); }; let routes = Router::new().route( "/install.sh", get(move |headers: HeaderMap| std::future::ready(script(public_url.as_deref(), &headers))), ); match source { Source::Dir(dir) => { routes.nest_service("/releases", tower_http::services::ServeDir::new(dir)) } Source::Repo(url) => { let records = Arc::new(Records::new(ctx, url, PACKAGE.into())); routes.merge( Router::new() .route("/releases/latest", get(latest)) .route("/releases/{version}/{file}", get(file)) .with_state(records), ) } } } /// Names each file being kept apart, until it is moved into place. static KEEPING: AtomicU64 = AtomicU64::new(0); /// Where release files are kept, by their SHA-256, whatever releases them. pub(crate) fn kept_dir(ctx: &Ctx) -> PathBuf { ctx.env.cache_dir.join("serve").join("releases") } /// A repository's releases of a package, as they are handed out. pub(crate) struct Records { ctx: Ctx, url: RepoUrl, package: String, /// Where each file read is kept, by its SHA-256. kept: PathBuf, /// The releases last listed, and when. listed: tokio::sync::Mutex>)>>, } impl Records { /// `url`'s releases of `package`, read as `ctx`, each file kept under /// its cache directory. pub(crate) fn new(ctx: Ctx, url: RepoUrl, package: String) -> Records { let kept = kept_dir(&ctx); Records { ctx, url, package, kept, listed: tokio::sync::Mutex::default(), } } /// The repository's releases of its package, read again once listed /// [`LISTED_FOR`] ago. pub(crate) async fn list(&self) -> anyhow::Result>> { let mut listed = self.listed.lock().await; if let Some((at, releases)) = listed.as_ref() && at.elapsed() < LISTED_FOR { return Ok(releases.clone()); } let releases = release::list(&self.ctx, &self.url, Some(&self.package)).await?; let releases = Arc::new(releases); *listed = Some((Instant::now(), releases.clone())); Ok(releases) } /// Lists the releases again when next asked, as once one is made. pub(crate) async fn forget(&self) { *self.listed.lock().await = None; } /// Where `file` is kept: read first, if it is not yet, from the owner's /// PDS a part at a time into a file of its own, checked, and moved into /// place, for good, as a release's files never change. pub(crate) async fn fetch( &self, file: &knotless::records::ReleaseFile, ) -> anyhow::Result { let hash = &file.sha256; anyhow::ensure!(release::valid_sha256(hash), "{hash:?} is not a SHA-256"); let at = self.kept.join(hash); if tokio::fs::try_exists(&at).await? { return Ok(at); } let remote = Remote::open(&self.ctx, &self.url).await?; tokio::fs::create_dir_all(&self.kept).await?; let n = KEEPING.fetch_add(1, Ordering::Relaxed); let keeping = self.kept.join(format!(".{}.{n}", file.sha256)); release::read_into(&remote, file, &keeping).await?; tokio::fs::rename(&keeping, &at).await?; Ok(at) } /// `file`'s bytes, as [`Records::fetch`] keeps them: for what is small, /// a manifest or an index. pub(crate) async fn read( &self, file: &knotless::records::ReleaseFile, ) -> anyhow::Result> { Ok(tokio::fs::read(self.fetch(file).await?).await?) } /// `file`, as a response's body read from where it is kept as it is /// sent, with its length, and `headers`. pub(crate) async fn send( &self, file: &knotless::records::ReleaseFile, headers: [(HeaderName, String); N], ) -> anyhow::Result { let kept = tokio::fs::File::open(self.fetch(file).await?).await?; let body = axum::body::Body::from_stream(tokio_util::io::ReaderStream::new(kept)); let length = [(header::CONTENT_LENGTH, file.size.to_string())]; Ok((length, headers, body).into_response()) } } /// Where this server is reached: `public_url`, or else the host it was /// asked for, as a proxy in front of it says. pub(crate) fn base_url(public_url: Option<&str>, headers: &HeaderMap) -> String { match public_url { Some(url) => url.trim_end_matches('/').to_string(), None => { let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); let proto = header("x-forwarded-proto").unwrap_or("http"); let host = header(header::HOST.as_str()).unwrap_or("127.0.0.1:2590"); format!("{proto}://{host}") } } } fn script(public_url: Option<&str>, headers: &HeaderMap) -> Response { let base = base_url(public_url, headers); ( [(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], SCRIPT.replace("__KNOTLESS_URL__", &base), ) .into_response() } fn failed(status: StatusCode, e: impl std::fmt::Display) -> Response { (status, format!("{e:#}\n")).into_response() } fn text(body: String) -> Response { ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response() } async fn latest(State(records): State>) -> Response { let releases = match records.list().await { Ok(releases) => releases, Err(e) => return failed(StatusCode::BAD_GATEWAY, e), }; match release::latest(&releases, PACKAGE) { Some(r) => text(format!("{}\n", r.version)), None => failed(StatusCode::NOT_FOUND, "no release"), } } /// A file of a version, or `.sha256`, its checksum as `shasum` /// writes one. async fn file( State(records): State>, Params((version, name)): Params<(String, String)>, ) -> Response { let releases = match records.list().await { Ok(releases) => releases, Err(e) => return failed(StatusCode::BAD_GATEWAY, e), }; let Some(release) = releases.iter().find(|r| r.version == version) else { return failed(StatusCode::NOT_FOUND, format!("no release {version}")); }; let named = |n: &str| release.files.iter().find(|f| f.name == n); if let Some(f) = name.strip_suffix(".sha256").and_then(named) { return text(format!("{} {}\n", f.sha256, f.name)); } let Some(f) = named(&name) else { return failed(StatusCode::NOT_FOUND, format!("{version} has no {name}")); }; let octets = [(header::CONTENT_TYPE, "application/octet-stream".to_string())]; match records.send(f, octets).await { Ok(sent) => sent, Err(e) => failed(StatusCode::BAD_GATEWAY, e), } } #[cfg(test)] mod tests { use super::*; // A record's hash names the file its bytes are kept in: one that is // not a SHA-256 is refused before it names a path. #[tokio::test] async fn a_file_is_kept_only_by_its_hash() { let ctx = Ctx::new(knotless::env::Env::from_vars(|_| None)); let url = RepoUrl { authority: "did:plc:a".into(), name: "r".into(), }; let records = Records::new(ctx, url, "p".into()); let file = knotless::records::ReleaseFile { name: "x".into(), size: 0, sha256: format!("{:.<64}", "../../../../etc/passwd"), parts: Vec::new(), }; let refused = records.fetch(&file).await.unwrap_err().to_string(); assert!(refused.contains("is not a SHA-256"), "{refused}"); } }