aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-01-12 18:39:11 +0000
committerNikolay Govorov <me@govorov.online>2026-01-12 19:03:36 +0000
commit86c38be2510084bb4b6dd6ea181703c34742cc65 (patch)
treeeae7772460dc651e6186d2ace6d6eba0db3abf8d
parentcbb993457ada8b8528748391b2cb8ee538d22dfa (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Uses a manual parser for zig tarballs instead of regex, fixes config issues
Diffstat
-rw-r--r--.gitignore1+1 −0
-rw-r--r--Cargo.lock77+14 −63
-rw-r--r--Cargo.toml5+3 −2
-rw-r--r--pkg/zorian.service6+5 −1
-rw-r--r--pkg/zorian.toml11+10 −1
-rw-r--r--src/controller_zig.rs175+144 −31
-rw-r--r--src/main.rs43+21 −22
-rw-r--r--src/service_config.rs86+67 −19
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",
]
@@ -2827,15 +2827,6 @@ dependencies = [
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
@@ -3282,26 +3273,14 @@ dependencies = [
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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",
@@ -3319,15 +3298,6 @@ dependencies = [
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
@@ -3336,20 +3306,6 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3359,12 +3315,6 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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 <me@govorov.online>
# 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<Self, ParseError> {
+ 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<service_config::ConfigService>,
storage: Arc<service_storage::StorageService>,
upstream: Arc<service_upstream::UpstreamService>,
}
impl ZigController {
pub fn new(
+ config: Arc<service_config::ConfigService>,
storage: Arc<service_storage::StorageService>,
upstream: Arc<service_upstream::UpstreamService>,
) -> Self {
- Self { storage, upstream }
+ Self {
+ config,
+ storage,
+ upstream,
+ }
}
pub fn router(self: Arc<Self>) -> 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<Arc<Self>>,
extract::Path(filename): extract::Path<String>,
) -> Result<response::Response, http::StatusCode> {
- 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<String> {
- 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<regex::Regex> = 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=<path>
+Usage: zorian [--config=<path>]
Options:
+ --config=<path> 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 <me@govorov.online>
// 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<Self, ConfigError> {
- 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<Self, ConfigError> {
+ 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")
- }
}