From 86c38be2510084bb4b6dd6ea181703c34742cc65 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Mon, 12 Jan 2026 18:39:11 +0000 Subject: Uses a manual parser for zig tarballs instead of regex, fixes config issues --- .gitignore | 1 + Cargo.lock | 77 ++++--------------- Cargo.toml | 5 +- pkg/zorian.service | 6 +- pkg/zorian.toml | 11 ++- src/controller_zig.rs | 175 ++++++++++++++++++++++++++++++++++-------- src/main.rs | 43 +++++------ src/service_config.rs | 86 ++++++++++++++++----- 8 files changed, 265 insertions(+), 139 deletions(-) diff --git a/.gitignore b/.gitignore index 4f96631..da6e2cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /dist +/.zorian-state diff --git a/Cargo.lock b/Cargo.lock index 277fba5..4618da4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,7 +309,7 @@ checksum = "cf53e0ebbbc6e45357b199f3b213f3eb330792c8b370e548499f5685470ecb11" dependencies = [ "semver", "serde", - "toml 0.9.11+spec-1.1.0", + "toml", "url", ] @@ -690,9 +690,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -817,9 +817,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -2554,7 +2554,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2665,7 +2665,7 @@ dependencies = [ "semver", "serde", "thiserror 2.0.17", - "toml 0.9.11+spec-1.1.0", + "toml", "url", ] @@ -2825,15 +2825,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.0.4" @@ -3280,18 +3271,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - [[package]] name = "toml" version = "0.9.11+spec-1.1.0" @@ -3300,8 +3279,8 @@ checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -3317,15 +3296,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -3335,20 +3305,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow", -] - [[package]] name = "toml_parser" version = "1.0.6+spec-1.1.0" @@ -3358,12 +3314,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" @@ -4020,9 +3970,9 @@ checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" [[package]] name = "zorian" @@ -4036,10 +3986,11 @@ dependencies = [ "hyper-tls", "hyper-util", "minijinja", - "regex", + "semver", "serde", + "thiserror 2.0.17", "tokio", - "toml 0.8.23", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9181e07..ea0261a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,11 @@ hyper = "1.8.1" hyper-tls = "0.6.0" hyper-util = { version = "0.1.19", features = ["client", "http1", "http2", "tokio"] } minijinja = "2.14.0" -regex = "1.12.2" +semver = "1.0" serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" tokio = { version = "1.49.0", features = ["full"] } -toml = "0.8" +toml = "0.9.8" [dev-dependencies] cargo-deny = "0.19.0" diff --git a/pkg/zorian.service b/pkg/zorian.service index 23f0b43..2f229ce 100644 --- a/pkg/zorian.service +++ b/pkg/zorian.service @@ -17,8 +17,12 @@ ExecPaths=/usr/local/bin/zorian /usr/lib ExecStart=/usr/local/bin/zorian --config=/etc/zorian.toml LimitCORE=infinity LimitNOFILE=500000 + # %p is resolved to the systemd unit name -RuntimeDirectory=%p +LogsDirectory=%p +StateDirectory=%p +CacheDirectory=%p +RuntimeDirectory=%p LockPersonality=yes NoNewPrivileges=yes diff --git a/pkg/zorian.toml b/pkg/zorian.toml index ffe4159..ceb94a6 100644 --- a/pkg/zorian.toml +++ b/pkg/zorian.toml @@ -1,5 +1,14 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -addr="0.0.0.0:3000" +# Zorian will dutifully indicate its user agent and protocols (for example, source for zig). +# Specify your name if you want your instance to be identified. +appname="zorian" + +# The address to which the server will respond via HTTP. +# Use a reverse proxy if you want to add TLS. +listen="0.0.0.0:3000" + +# Path to the directory for storing state (indexes, caches, statistics). +# Must be a writable directory. dirname="/var/lib/zorian" diff --git a/src/controller_zig.rs b/src/controller_zig.rs index 9e0b18c..41b8b0a 100644 --- a/src/controller_zig.rs +++ b/src/controller_zig.rs @@ -2,36 +2,174 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use axum::{Router, body, extract, http, response, routing}; -use std::sync::{Arc, OnceLock}; +use semver::Version; +use std::sync::Arc; +use thiserror::Error; +use crate::service_config; use crate::service_storage; use crate::service_upstream; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Archive { + Zip, + TarXz, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TarballType<'a> { + Source, + Bootstrap, + Binary { os: &'a str, arch: &'a str }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("invalid tarball filename")] +struct ParseError; + +/// Describes a single file stored at `ziglang.org/download/`. +/// +/// The tarball naming has changed several times. When parsing, +/// we standardize the files, but for the reverse operation +/// (getting a string from a tarball), we preserve the original path. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Tarball<'a> { + filename: &'a str, + tarball_type: TarballType<'a>, + minisig: bool, + archive: Archive, + version: Version, + development: bool, +} + +impl<'a> Tarball<'a> { + pub fn parse(filename: &'a str) -> Result { + let mut buffer = filename; + let mut minisig = false; + let archive; + let tarball_type; + + // (?:|-bootstrap|-[a-zA-Z0-9_]+-[a-zA-Z0-9_]+)-(\d+\.\d+\.\d+(?:-dev\.\d+\+[0-9a-f]+)?)\.(?:tar\.xz|zip)(?:\.minisig)? + buffer = buffer.strip_prefix("zig-").ok_or(ParseError)?; + + // (?:|bootstrap|[a-zA-Z0-9_]+-[a-zA-Z0-9_]+)-(\d+\.\d+\.\d+(?:-dev\.\d+\+[0-9a-f]+)?)\.(?:tar\.xz|zip) + if let Some(it) = buffer.strip_suffix(".minisig") { + buffer = it; + minisig = true; + } + + // (?:|bootstrap|[a-zA-Z0-9_]+-[a-zA-Z0-9_]+)-(\d+\.\d+\.\d+(?:-dev\.\d+\+[0-9a-f]+)?) + if let Some(it) = buffer.strip_suffix(".zip") { + buffer = it; + archive = Archive::Zip; + } else if let Some(it) = buffer.strip_suffix(".tar.xz") { + buffer = it; + archive = Archive::TarXz; + } else { + return Err(ParseError); + } + + if buffer.is_empty() { + return Err(ParseError); + } + + let mut it = buffer.rsplit('-'); + let last = it.next().ok_or(ParseError)?; + + let development = last.starts_with("dev"); + + let version = if !development { + Version::parse(last).map_err(|_| ParseError)? + } else { + let semver = it.next().ok_or(ParseError)?; + let devver = last; + let version_str = format!("{}-{}", semver, devver); + Version::parse(&version_str).map_err(|_| ParseError)? + }; + + if let Some(payload) = it.next() { + if payload == "bootstrap" { + tarball_type = TarballType::Bootstrap; + } else { + // Version 0.14.0 is the last one to use the OS-ARCH format in names; newer versions use ARCH-OS. + let min_version = Version::new(0, 14, 0); + if version > min_version { + tarball_type = TarballType::Binary { + os: payload, + arch: it.next().ok_or(ParseError)?, + }; + } else { + tarball_type = TarballType::Binary { + arch: payload, + os: it.next().ok_or(ParseError)?, + }; + } + } + } else { + tarball_type = TarballType::Source; + } + + if it.next().is_some() { + return Err(ParseError); + } + + Ok(Tarball { + filename, + tarball_type, + minisig, + archive, + version, + development, + }) + } + + /// Builds the upstream URL for this tarball. + pub fn upstream_url(&self, source: &str) -> String { + if self.development { + format!( + "https://ziglang.org/builds/{}?source={}", + self.filename, source + ) + } else { + format!( + "https://ziglang.org/download/{}/{}?source={}", + self.version, self.filename, source, + ) + } + } +} + pub struct ZigController { + config: Arc, storage: Arc, upstream: Arc, } impl ZigController { pub fn new( + config: Arc, storage: Arc, upstream: Arc, ) -> Self { - Self { storage, upstream } + Self { + config, + storage, + upstream, + } } pub fn router(self: Arc) -> Router { Router::new() - .route("/zig/{filename}", routing::get(Self::download)) + .route("/zig/{filename}", routing::get(Self::handle)) .with_state(self) } - async fn download( + async fn handle( extract::State(controller): extract::State>, extract::Path(filename): extract::Path, ) -> Result { - let version = Self::parse_version(&filename).ok_or(http::StatusCode::NOT_FOUND)?; - let url = Self::build_upstream_url(&filename, &version); + let tarball = Tarball::parse(&filename).map_err(|_| http::StatusCode::NOT_FOUND)?; + let url = tarball.upstream_url(controller.config.appname()); match controller.storage.get(&filename).await { Ok(Some(entry)) => { @@ -61,31 +199,6 @@ impl ZigController { Ok(Self::build_response(http::StatusCode::OK, cache_entry)) } - fn parse_version(filename: &str) -> Option { - let re = Self::filename_regex(); - re.captures(filename) - .and_then(|captures| captures.get(1)) - .map(|match_| match_.as_str().to_string()) - } - - fn filename_regex() -> &'static regex::Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - regex::Regex::new( - r"^zig(?:|-bootstrap|-[a-zA-Z0-9_]+-[a-zA-Z0-9_]+)-(\d+\.\d+\.\d+(?:-dev\.\d+\+[0-9a-f]+)?)\.(?:tar\.xz|zip)(?:\.minisig)?$", - ) - .unwrap() - }) - } - - fn build_upstream_url(filename: &str, version: &str) -> String { - if version.contains("-dev.") { - format!("https://ziglang.org/builds/{filename}") - } else { - format!("https://ziglang.org/download/{version}/{filename}") - } - } - fn build_response( status: http::StatusCode, entry: service_storage::File, diff --git a/src/main.rs b/src/main.rs index ed44b60..6dd25a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,9 +13,10 @@ mod service_upstream; const VERSION: &str = env!("CARGO_PKG_VERSION"); const HELP: &str = "\ -Usage: zorian --config= +Usage: zorian [--config=] Options: + --config= Path to config file (optional) --help Show this help message --version Show version "; @@ -23,7 +24,6 @@ Options: #[tokio::main] async fn main() { let mut config_path = None; - for arg in std::env::args().skip(1) { if arg == "--help" || arg == "-h" { print!("{HELP}"); @@ -38,36 +38,35 @@ async fn main() { } } - let config_path = config_path.expect("missing --config argument"); - - let config = match service_config::ConfigService::from_file(&config_path).await { - Ok(config) => Arc::new(config), - Err(service_config::ConfigError::Io(e)) => { - eprintln!( - "error: failed to read config file '{}': {e}", - config_path.display() - ); - std::process::exit(1); - } - Err(service_config::ConfigError::Parse(e)) => { - eprintln!( - "error: failed to parse config file '{}': {e}", - config_path.display() - ); + let config = Arc::new(match config_path { + Some(path) => service_config::ConfigService::from_file(&path).unwrap_or_else(|e| { + eprintln!("error: {e}"); std::process::exit(1); - } - }; + }), + None => service_config::ConfigService::default(), + }); + config.validate().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + let storage = Arc::new(service_storage::StorageService::new(config.clone())); let upstream = Arc::new(service_upstream::UpstreamService::new()); let web_controller = Arc::new(controller_web::WebController::new()); - let zig_controller = Arc::new(controller_zig::ZigController::new(storage, upstream)); + let zig_controller = Arc::new(controller_zig::ZigController::new( + config.clone(), + storage.clone(), + upstream.clone(), + )); let app = Router::new() .merge(web_controller.router()) .merge(zig_controller.router()); - let listener = tokio::net::TcpListener::bind(config.addr()).await.unwrap(); + let listener = tokio::net::TcpListener::bind(config.listen()) + .await + .unwrap(); println!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); diff --git a/src/service_config.rs b/src/service_config.rs index ab7e1c1..dd55bfc 100644 --- a/src/service_config.rs +++ b/src/service_config.rs @@ -1,45 +1,93 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later -use std::io; +use std::fs; use std::path::{Path, PathBuf}; use serde::Deserialize; -use tokio::fs; +use thiserror::Error; -#[derive(Debug)] +#[derive(Debug, Error)] pub enum ConfigError { - Io(io::Error), - Parse(toml::de::Error), + #[error("failed to read config file: {0}")] + Io(#[from] std::io::Error), + + #[error("failed to parse config file: {0}")] + Parse(#[from] toml::de::Error), + + #[error("appname '{0}' contains invalid characters (only a-z, A-Z, 0-9, -, _ allowed)")] + InvalidAppname(String), + + #[error("dirname '{0}' does not exist")] + DirNotFound(PathBuf), + + #[error("dirname '{0}' is not a directory")] + NotADirectory(PathBuf), + + #[error("dirname '{0}' is not writable: {1}")] + NotWritable(PathBuf, std::io::Error), } #[derive(Debug, Deserialize)] +#[serde(default)] pub struct ConfigService { - #[serde(default = "ConfigService::default_addr")] - addr: String, - - #[serde(default = "ConfigService::default_dirname")] + listen: String, + appname: String, dirname: PathBuf, } +impl Default for ConfigService { + fn default() -> Self { + Self { + listen: "0.0.0.0:3000".to_string(), + appname: "zorian".to_string(), + dirname: PathBuf::from("./.zorian-state"), + } + } +} + impl ConfigService { - pub async fn from_file(path: &Path) -> Result { - let content = fs::read_to_string(path).await.map_err(ConfigError::Io)?; - let config = toml::from_str(&content).map_err(ConfigError::Parse)?; + pub fn from_file(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let config: Self = toml::from_str(&content)?; Ok(config) } - pub fn addr(&self) -> &str { - &self.addr + pub fn validate(&self) -> Result<(), ConfigError> { + let mut chars = self.appname.chars(); + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(ConfigError::InvalidAppname(self.appname.clone())); + } + + let metadata = fs::metadata(&self.dirname).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ConfigError::DirNotFound(self.dirname.clone()) + } else { + ConfigError::Io(e) + } + })?; + + if !metadata.is_dir() { + return Err(ConfigError::NotADirectory(self.dirname.clone())); + } + + let testfile = self.dirname.join(".health"); + fs::write(&testfile, std::process::id().to_string()) + .map_err(|e| ConfigError::NotWritable(self.dirname.clone(), e))?; + fs::remove_file(&testfile)?; + + Ok(()) } - fn default_addr() -> String { - "0.0.0.0:3000".to_string() + + pub fn appname(&self) -> &str { + &self.appname + } + + pub fn listen(&self) -> &str { + &self.listen } pub fn dirname(&self) -> &Path { &self.dirname } - fn default_dirname() -> PathBuf { - PathBuf::from("./zorian-storage") - } } -- Gilti