johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<Source, String> {
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<Source>, public_url: Option<String>, 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<Option<(Instant, Arc<Vec<ReleaseRecord>>)>>,
}
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<Arc<Vec<ReleaseRecord>>> {
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<PathBuf> {
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<Vec<u8>> {
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<const N: usize>(
&self,
file: &knotless::records::ReleaseFile,
headers: [(HeaderName, String); N],
) -> anyhow::Result<Response> {
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<Arc<Records>>) -> 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 `<file>.sha256`, its checksum as `shasum`
/// writes one.
async fn file(
State(records): State<Arc<Records>>,
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}");
}
}