#[cfg(test)]
#[path = "tests/server.rs"]
mod tests;
use crate::responses::{LatestVersionResponse, VersionHashResponse};
use axum::{
Extension,
Json,
body::{Body, Bytes},
extract::Path,
http::{StatusCode, header::CONTENT_LENGTH, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use ed25519_dalek::Signer as _;
use rubedo::{
crypto::{Sha256Hash, SigningKey},
http::ResponseExt as _,
std::FileExt as _,
sugar::s,
};
use semver::Version;
use std::{
collections::HashMap,
fs::File,
io::ErrorKind as IoErrorKind,
path::PathBuf,
sync::Arc,
};
use thiserror::Error as ThisError;
use tokio::{
fs::File as AsyncFile,
io::{AsyncReadExt as _, BufReader},
};
use tokio_util::io::ReaderStream;
use tracing::error;
#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
#[non_exhaustive]
pub enum ReleaseError {
#[error("The release file for version {0} failed hash verification: {1:?}")]
Invalid(Version, PathBuf),
#[error("The release file for version {0} is missing: {1:?}")]
Missing(Version, PathBuf),
#[error("The release file for version {0} cannot be read: {1}: {2}")]
Unreadable(Version, IoErrorKind, String),
}
#[expect(clippy::exhaustive_structs, reason = "Provided for configuration")]
#[derive(Clone, Debug)]
pub struct Config {
pub appname: String,
pub key: SigningKey,
pub releases: PathBuf,
pub stream_threshold: u64,
pub stream_buffer: usize,
pub read_buffer: usize,
pub versions: HashMap<Version, Sha256Hash>
}
#[derive(Clone, Debug)]
pub struct Core {
config: Config,
latest: Version,
}
impl Core {
pub fn new(config: Config) -> Result<Self, ReleaseError> {
#[expect(clippy::iter_over_hash_type, reason = "Order doesn't matter here")]
for (version, hash) in &config.versions {
let path = config.releases.join(format!("{}-{}", config.appname, version));
if !path.exists() || !path.is_file() {
return Err(ReleaseError::Missing(version.clone(), path));
}
let file_hash: Sha256Hash = File::hash(&path).map_err(|err|
ReleaseError::Unreadable(version.clone(), err.kind(), err.to_string())
)?;
if file_hash != *hash {
return Err(ReleaseError::Invalid(version.clone(), path));
}
}
let latest = config.versions.keys().max().unwrap_or(&Version::new(0, 0, 0)).clone();
Ok(Self {
config,
latest,
})
}
#[must_use]
pub fn latest_version(&self) -> Version {
self.latest.clone()
}
#[must_use]
pub fn versions(&self) -> HashMap<Version, Sha256Hash> {
self.config.versions.clone()
}
#[must_use]
pub fn release_file(&self, version: &Version) -> Option<PathBuf> {
self.versions()
.get(version)
.map(|_hash| self.config.releases.join(format!("{}-{}", self.config.appname, version)))
}
}
#[derive(Copy, Clone, Debug)]
#[non_exhaustive]
pub struct Axum;
impl Axum {
#[expect(clippy::unused_async, reason = "Consistent and future-proof")]
pub async fn get_latest_version(
Extension(core): Extension<Arc<Core>>,
) -> impl IntoResponse {
Self::sign_response(&core.config.key, Json(LatestVersionResponse {
version: core.latest_version(),
}).into_response())
}
#[expect(clippy::unused_async, reason = "Consistent and future-proof")]
pub async fn get_hash_for_version(
Extension(core): Extension<Arc<Core>>,
Path(version): Path<Version>,
) -> impl IntoResponse {
match core.versions().get(&version) {
Some(hash) => Ok(Self::sign_response(&core.config.key, Json(VersionHashResponse {
version,
hash: *hash,
}).into_response())),
None => Err((StatusCode::NOT_FOUND, format!("Version {version} not found"))),
}
}
#[expect(clippy::missing_panics_doc, reason = "Infallible")]
pub async fn get_release_file(
Extension(core): Extension<Arc<Core>>,
Path(version): Path<Version>,
) -> impl IntoResponse {
let Some(path) = core.release_file(&version) else {
return Err((StatusCode::NOT_FOUND, format!("Version {version} not found")));
};
if !path.exists() || !path.is_file() {
error!("Release file missing: {path:?}");
return Err((StatusCode::INTERNAL_SERVER_ERROR, s!("Release file missing")));
}
let mut file = match AsyncFile::open(&path).await {
Ok(file) => file,
Err(err) => {
error!("Cannot open release file: {path:?}, error: {err}");
return Err((StatusCode::INTERNAL_SERVER_ERROR, s!("Cannot open release file")));
},
};
let metadata = match file.metadata().await {
Ok(metadata) => metadata,
Err(err) => {
error!("Cannot read release file metadata: {path:?}, error: {err}");
return Err((StatusCode::INTERNAL_SERVER_ERROR, s!("Cannot read release file metadata")));
},
};
let body = if metadata.len() > core.config.stream_threshold.saturating_mul(1024) {
let reader = BufReader::with_capacity(core.config.read_buffer.saturating_mul(1024), file);
let stream = ReaderStream::with_capacity(reader, core.config.stream_buffer.saturating_mul(1024));
Body::from_stream(stream)
} else {
let mut contents = vec![];
match file.read_to_end(&mut contents).await {
Ok(_) => (),
Err(err) => {
error!("Cannot read release file: {path:?}, error: {err}");
return Err((StatusCode::INTERNAL_SERVER_ERROR, s!("Cannot read release file")));
},
}
Body::from(contents)
};
#[expect(clippy::unwrap_used, reason = "Infallible")]
Ok(Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "application/octet-stream")
.header(CONTENT_LENGTH, metadata.len())
.body(body)
.unwrap()
)
}
#[expect(clippy::missing_panics_doc, reason = "Infallible")]
#[expect(clippy::unwrap_used, reason = "Infallible")]
#[must_use]
pub fn sign_response(key: &SigningKey, mut response: Response) -> Response {
let unpacked_response = response.unpack().unwrap();
let mut signed_response = Response::builder()
.status(unpacked_response.status)
.header("X-Signature", key.sign(unpacked_response.body.as_ref()).to_string())
.body(Body::from(Bytes::from(unpacked_response.body.into_bytes())))
.unwrap()
;
signed_response.headers_mut().extend(response.headers().clone());
signed_response.into_response()
}
}