~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
279 lines · 9.9 KB Raw History
//! 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}");
    }
}