aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--.github/workflows/build.yml16+15 −1
-rw-r--r--Cargo.lock48+46 −2
-rw-r--r--Cargo.toml2+1 −1
-rw-r--r--Makefile8+8 −0
-rw-r--r--crates/zorian/src/config.rs26+16 −10
-rw-r--r--deny.toml1+1 −0
-rw-r--r--tests/smoke/Cargo.toml18+18 −0
-rwxr-xr-xtests/smoke/run-local.sh77+77 −0
-rw-r--r--tests/smoke/src/main.rs403+403 −0
9 files changed, 585 insertions, 14 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4d1c4b3..7e70498 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -106,10 +106,24 @@ jobs:
name: packages-${{ matrix.arch }}
path: dist/*
+ smoke:
+ name: Smoke tests
+ runs-on: ubuntu-24.04
+ needs: [build]
+ if: github.ref == 'refs/heads/main'
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
+ - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
+
+ - name: Run smoke tests against local instance
+ run: ./tests/smoke/run-local.sh
+
publish:
name: Publish dev build
runs-on: ubuntu-latest
- needs: [build]
+ needs: [build, smoke]
if: github.ref == 'refs/heads/main'
permissions:
contents: write
diff --git a/Cargo.lock b/Cargo.lock
index e608820..ee96265 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1575,7 +1575,7 @@ dependencies = [
"bytes",
"http",
"opentelemetry",
- "reqwest",
+ "reqwest 0.12.28",
]
[[package]]
@@ -1590,7 +1590,7 @@ dependencies = [
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
- "reqwest",
+ "reqwest 0.12.28",
"thiserror 2.0.18",
"tokio",
"tonic",
@@ -2066,6 +2066,41 @@ dependencies = [
]
[[package]]
+name = "reqwest"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2413,6 +2448,15 @@ dependencies = [
]
[[package]]
+name = "smoke"
+version = "0.1.0"
+dependencies = [
+ "hex",
+ "reqwest 0.13.2",
+ "sha2",
+]
+
+[[package]]
name = "socket2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 19d2306..d073fdc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
[workspace]
-members = ["crates/*", "xtask"]
+members = ["crates/*", "tests/smoke", "xtask"]
resolver = "3"
[workspace.package]
diff --git a/Makefile b/Makefile
index 87b77ab..2a7b558 100644
--- a/Makefile
+++ b/Makefile
@@ -33,6 +33,14 @@ test:
cargo build --release
cargo test --all-features --release --locked
+.PHONY: smoke
+smoke:
+ cargo run -p smoke
+
+.PHONY: smoke-local
+smoke-local:
+ ./tests/smoke/run-local.sh
+
.PHONY: coverage
coverage:
mkdir -p target/coverage/
diff --git a/crates/zorian/src/config.rs b/crates/zorian/src/config.rs
index 5719d22..79da442 100644
--- a/crates/zorian/src/config.rs
+++ b/crates/zorian/src/config.rs
@@ -227,6 +227,7 @@ pub struct BackendsConfig {
}
#[derive(Debug, Deserialize)]
+#[serde(default)]
pub struct ConfigService {
appname: String,
dirname: PathBuf,
@@ -236,6 +237,19 @@ pub struct ConfigService {
backends: BackendsConfig,
}
+impl Default for ConfigService {
+ fn default() -> Self {
+ Self {
+ appname: "zorian".to_string(),
+ dirname: PathBuf::from("./.zorian-state"),
+ listen: vec![ListenerConfig::default()],
+ server: ServerConfig::default(),
+ telemetry: TelemetryConfig::default(),
+ backends: BackendsConfig::default(),
+ }
+ }
+}
+
impl ConfigService {
pub fn load(config_path: Option<PathBuf>) -> Result<Self, ConfigError> {
let config = match config_path {
@@ -243,19 +257,11 @@ impl ConfigService {
info!("use config file from {}", path.to_str().unwrap());
let content = fs::read_to_string(path)?;
- let config: Self = toml::from_str(&content)?;
- config
+ toml::from_str(&content)?
}
None => {
info!("configuration file path not provided");
- Self {
- appname: "zorian".to_string(),
- dirname: PathBuf::from("./.zorian-state"),
- server: ServerConfig::default(),
- listen: vec![ListenerConfig::default()],
- telemetry: TelemetryConfig::default(),
- backends: BackendsConfig::default(),
- }
+ Self::default()
}
};
diff --git a/deny.toml b/deny.toml
index 563ab6a..f9d0ba0 100644
--- a/deny.toml
+++ b/deny.toml
@@ -26,6 +26,7 @@ exceptions = [
{ crate = "base", allow = ["AGPL-3.0-or-later"] },
{ crate = "repos", allow = ["AGPL-3.0-or-later"] },
{ crate = "zorian", allow = ["AGPL-3.0-or-later"] },
+ { crate = "smoke", allow = ["AGPL-3.0-or-later"] },
]
unused-allowed-license = "allow"
diff --git a/tests/smoke/Cargo.toml b/tests/smoke/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/tests/smoke/Cargo.toml
@@ -0,0 +1,18 @@
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[package]
+name = "smoke"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "smoke"
+path = "src/main.rs"
+
+[dependencies]
+hex.workspace = true
+reqwest = { version = "0.13", features = ["blocking", "native-tls"], default-features = false }
+sha2 = "0.10"
diff --git a/tests/smoke/run-local.sh b/tests/smoke/run-local.sh
new file mode 100755
--- /dev/null
+++ b/tests/smoke/run-local.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# Builds zorian, starts it in a temporary directory, runs smoke tests, cleans up.
+#
+# Usage:
+# ./tests/smoke/run-local.sh # default port 2025
+# ZORIAN_PORT=9999 ./tests/smoke/run-local.sh
+
+set -euo pipefail
+
+PORT="${ZORIAN_PORT:-2025}"
+BASE_URL="http://127.0.0.1:${PORT}"
+
+TMPDIR="$(mktemp -d)"
+LOGFILE="$TMPDIR/zorian.log"
+trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$TMPDIR"' EXIT
+
+# Build
+echo "Building zorian..."
+cargo build --release -p zorian
+
+# Write minimal config
+cat > "$TMPDIR/zorian.toml" <<EOF
+appname = "smoke"
+dirname = "$TMPDIR/state"
+
+[[listen]]
+addr = "127.0.0.1:${PORT}"
+hostnames = []
+
+[server]
+shutdown_timeout = 5
+request_timeout = 600
+max_body_size = "64 MB"
+max_concurrent_requests = 64
+rate_limit_period = 1
+rate_limit_burst_size = 200
+
+[telemetry.stdout]
+enabled = true
+log_level = "info"
+log_format = "pretty"
+EOF
+
+mkdir -p "$TMPDIR/state"
+
+# Start zorian
+echo "Starting zorian on ${BASE_URL}..."
+echo "Server log: ${LOGFILE}"
+cargo run --release -p zorian -- --config="$TMPDIR/zorian.toml" >"$LOGFILE" 2>&1 &
+PID=$!
+
+# Wait for index to load (log shows "index refreshed" for each backend)
+echo "Waiting for index to load..."
+for i in $(seq 1 120); do
+ if grep -q "index refreshed" "$LOGFILE" 2>/dev/null; then
+ echo "Index loaded after ${i}s"
+ break
+ fi
+ if ! kill -0 "$PID" 2>/dev/null; then
+ echo "zorian exited unexpectedly. Server log:"
+ cat "$LOGFILE"
+ exit 1
+ fi
+ sleep 1
+done
+
+if ! grep -q "index refreshed" "$LOGFILE" 2>/dev/null; then
+ echo "Timed out waiting for index. Server log:"
+ cat "$LOGFILE"
+ exit 1
+fi
+
+# Run smoke tests
+ZORIAN_URL="${BASE_URL}" cargo run -p smoke
diff --git a/tests/smoke/src/main.rs b/tests/smoke/src/main.rs
new file mode 100644
--- /dev/null
+++ b/tests/smoke/src/main.rs
@@ -0,0 +1,403 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Black-box smoke tests for Zorian.
+//
+// Downloads archives from Zorian and upstream, compares sha256 hashes,
+// verifies that minisig signatures (Zig) and sha256 checksums (Go) match upstream.
+//
+// Usage:
+// ZORIAN_URL=https://pkg.earth cargo run -p smoke
+
+use sha2::{Digest, Sha256};
+
+const GO_FILES: &[&str] = &[
+ "go1.23.0.linux-amd64.tar.gz",
+ "go1.23.0.darwin-arm64.tar.gz",
+ "go1.21.0.windows-amd64.zip",
+];
+
+struct ZigTestFile {
+ file: &'static str,
+ version: &'static str,
+}
+
+const ZIG_FILES: &[ZigTestFile] = &[
+ // 0.15.2 (new tarball name format, some new targets)
+ ZigTestFile {
+ file: "zig-0.15.2.tar.xz",
+ version: "0.15.2",
+ },
+ ZigTestFile {
+ file: "zig-x86_64-windows-0.15.2.zip",
+ version: "0.15.2",
+ },
+ ZigTestFile {
+ file: "zig-aarch64-macos-0.15.2.tar.xz",
+ version: "0.15.2",
+ },
+ ZigTestFile {
+ file: "zig-aarch64-netbsd-0.15.2.tar.xz",
+ version: "0.15.2",
+ },
+ ZigTestFile {
+ file: "zig-powerpc64le-freebsd-0.15.2.tar.xz",
+ version: "0.15.2",
+ },
+ // Every 'zig-xxx-linux-0.14.1.tar.xz' (same order as on the website)
+ ZigTestFile {
+ file: "zig-x86_64-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-aarch64-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-armv7a-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-riscv64-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-powerpc64le-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-x86-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-loongarch64-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ ZigTestFile {
+ file: "zig-s390x-linux-0.14.1.tar.xz",
+ version: "0.14.1",
+ },
+ // 0.10.1 (last stage1 release)
+ ZigTestFile {
+ file: "zig-0.10.1.tar.xz",
+ version: "0.10.1",
+ },
+ ZigTestFile {
+ file: "zig-bootstrap-0.10.1.tar.xz",
+ version: "0.10.1",
+ },
+ ZigTestFile {
+ file: "zig-linux-i386-0.10.1.tar.xz",
+ version: "0.10.1",
+ },
+ ZigTestFile {
+ file: "zig-macos-aarch64-0.10.1.tar.xz",
+ version: "0.10.1",
+ },
+ ZigTestFile {
+ file: "zig-windows-x86_64-0.10.1.zip",
+ version: "0.10.1",
+ },
+ // 0.7.1 (oldest supported patch release)
+ ZigTestFile {
+ file: "zig-0.7.1.tar.xz",
+ version: "0.7.1",
+ },
+ ZigTestFile {
+ file: "zig-linux-x86_64-0.7.1.tar.xz",
+ version: "0.7.1",
+ },
+ // 0.6.0 (oldest supported version)
+ ZigTestFile {
+ file: "zig-0.6.0.tar.xz",
+ version: "0.6.0",
+ },
+ ZigTestFile {
+ file: "zig-linux-x86_64-0.6.0.tar.xz",
+ version: "0.6.0",
+ },
+ // 0.1.1 (first release)
+ ZigTestFile {
+ file: "zig-win64-0.1.1.zip",
+ version: "0.1.1",
+ },
+];
+
+struct Runner {
+ passed: u32,
+ failed: u32,
+ client: reqwest::blocking::Client,
+}
+
+impl Runner {
+ fn new() -> Self {
+ Self {
+ passed: 0,
+ failed: 0,
+ client: reqwest::blocking::Client::builder()
+ .timeout(std::time::Duration::from_secs(600))
+ .build()
+ .expect("failed to create HTTP client"),
+ }
+ }
+
+ fn ok(&mut self, name: &str) {
+ self.passed += 1;
+ println!(" \x1b[32mok\x1b[0m {name}");
+ }
+
+ fn fail(&mut self, name: &str, reason: &str) {
+ self.failed += 1;
+ println!(" \x1b[31mFAIL\x1b[0m {name}");
+ println!(" {reason}");
+ }
+
+ fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
+ let resp = self.client.get(url).send().map_err(|e| format!("{e}"))?;
+ if !resp.status().is_success() {
+ return Err(format!("{} for {url}", resp.status()));
+ }
+ resp.bytes().map(|b| b.to_vec()).map_err(|e| format!("{e}"))
+ }
+
+ fn fetch_status(&self, url: &str) -> Result<u16, String> {
+ let resp = self.client.get(url).send().map_err(|e| format!("{e}"))?;
+ Ok(resp.status().as_u16())
+ }
+
+ fn fetch_text(&self, url: &str) -> Result<(u16, String, String), String> {
+ let resp = self.client.get(url).send().map_err(|e| format!("{e}"))?;
+ let status = resp.status().as_u16();
+ let content_type = resp
+ .headers()
+ .get("content-type")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("")
+ .to_string();
+ let body = resp.text().map_err(|e| format!("{e}"))?;
+ Ok((status, content_type, body))
+ }
+}
+
+fn sha256hex(data: &[u8]) -> String {
+ hex::encode(Sha256::digest(data))
+}
+
+fn zig_upstream_url(file: &str, version: &str) -> String {
+ format!("https://ziglang.org/download/{version}/{file}")
+}
+
+fn test_web(r: &mut Runner, base_url: &str) {
+ println!("\n--- Web ---");
+
+ {
+ let name = "GET / returns html";
+ match r.fetch_text(&format!("{base_url}/")) {
+ Ok((status, ct, body)) => {
+ if status != 200 {
+ r.fail(name, &format!("status {status}, expected 200"));
+ } else if !ct.contains("text/html") {
+ r.fail(name, &format!("content-type: {ct}"));
+ } else if !body.contains("Zorian") {
+ r.fail(name, "body missing \"Zorian\"");
+ } else {
+ r.ok(name);
+ }
+ }
+ Err(e) => r.fail(name, &e),
+ }
+ }
+
+ {
+ let name = "GET /base.css returns css";
+ match r.fetch_text(&format!("{base_url}/base.css")) {
+ Ok((status, ct, body)) => {
+ if status != 200 {
+ r.fail(name, &format!("status {status}"));
+ } else if !ct.contains("text/css") {
+ r.fail(name, &format!("content-type: {ct}"));
+ } else if body.is_empty() {
+ r.fail(name, "empty body");
+ } else {
+ r.ok(name);
+ }
+ }
+ Err(e) => r.fail(name, &e),
+ }
+ }
+
+ {
+ let name = "GET /favicon.ico returns icon";
+ match r.fetch_status(&format!("{base_url}/favicon.ico")) {
+ Ok(200) => r.ok(name),
+ Ok(s) => r.fail(name, &format!("status {s}")),
+ Err(e) => r.fail(name, &e),
+ }
+ }
+
+ {
+ let name = "GET /about/licenses returns html";
+ match r.fetch_text(&format!("{base_url}/about/licenses")) {
+ Ok((status, ct, body)) => {
+ if status != 200 {
+ r.fail(name, &format!("status {status}"));
+ } else if !ct.contains("text/html") {
+ r.fail(name, &format!("content-type: {ct}"));
+ } else if !body.contains("AGPL") {
+ r.fail(name, "body missing \"AGPL\"");
+ } else {
+ r.ok(name);
+ }
+ }
+ Err(e) => r.fail(name, &e),
+ }
+ }
+
+ {
+ let name = "GET /does-not-exist returns 404";
+ match r.fetch_status(&format!("{base_url}/does-not-exist.xyz")) {
+ Ok(404) => r.ok(name),
+ Ok(s) => r.fail(name, &format!("status {s}, expected 404")),
+ Err(e) => r.fail(name, &e),
+ }
+ }
+}
+
+fn test_go(r: &mut Runner, base_url: &str) {
+ println!("\n--- Go ---");
+
+ for file in GO_FILES {
+ // Archive: compare sha256 with upstream
+ {
+ let name = format!("go: {file} matches upstream");
+ match (
+ r.fetch_bytes(&format!("{base_url}/go/{file}")),
+ r.fetch_bytes(&format!("https://go.dev/dl/{file}")),
+ ) {
+ (Ok(zorian), Ok(upstream)) => {
+ let zh = sha256hex(&zorian);
+ let uh = sha256hex(&upstream);
+ if zh != uh {
+ r.fail(&name, &format!("zorian={zh} upstream={uh}"));
+ } else {
+ r.ok(&name);
+ }
+ }
+ (Err(e), _) | (_, Err(e)) => r.fail(&name, &e),
+ }
+ }
+
+ // Checksum: compare .sha256 endpoint with computed hash
+ {
+ let name = format!("go: {file}.sha256 matches content");
+ match (
+ r.fetch_bytes(&format!("{base_url}/go/{file}")),
+ r.fetch_text(&format!("{base_url}/go/{file}.sha256")),
+ ) {
+ (Ok(archive), Ok((200, _, checksum_body))) => {
+ let computed = sha256hex(&archive);
+ let expected = checksum_body.trim();
+ if computed != expected {
+ r.fail(&name, &format!("computed={computed} endpoint={expected}"));
+ } else {
+ r.ok(&name);
+ }
+ }
+ (Ok(_), Ok((s, _, _))) => {
+ r.fail(&name, &format!("sha256 endpoint returned {s}"));
+ }
+ (Err(e), _) | (_, Err(e)) => r.fail(&name, &e),
+ }
+ }
+ }
+
+ {
+ let name = "go: nonexistent version returns 404";
+ match r.fetch_status(&format!("{base_url}/go/go99.99.99.linux-amd64.tar.gz")) {
+ Ok(404) => r.ok(name),
+ Ok(s) => r.fail(name, &format!("status {s}, expected 404")),
+ Err(e) => r.fail(name, &e),
+ }
+ }
+}
+
+fn test_zig(r: &mut Runner, base_url: &str) {
+ println!("\n--- Zig ---");
+
+ for entry in ZIG_FILES {
+ // Archive: compare sha256 with upstream
+ {
+ let name = format!("zig: {} matches upstream", entry.file);
+ match (
+ r.fetch_bytes(&format!("{base_url}/zig/{}", entry.file)),
+ r.fetch_bytes(&zig_upstream_url(entry.file, entry.version)),
+ ) {
+ (Ok(zorian), Ok(upstream)) => {
+ let zh = sha256hex(&zorian);
+ let uh = sha256hex(&upstream);
+ if zh != uh {
+ r.fail(&name, &format!("zorian={zh} upstream={uh}"));
+ } else {
+ r.ok(&name);
+ }
+ }
+ (Err(e), _) | (_, Err(e)) => r.fail(&name, &e),
+ }
+ }
+
+ // Signature: compare .minisig with upstream
+ {
+ let name = format!("zig: {}.minisig matches upstream", entry.file);
+ let zorian_url = format!("{base_url}/zig/{}.minisig", entry.file);
+ let upstream_url = format!("{}.minisig", zig_upstream_url(entry.file, entry.version));
+ match (r.fetch_bytes(&zorian_url), r.fetch_bytes(&upstream_url)) {
+ (Ok(zorian), Ok(upstream)) => {
+ if zorian != upstream {
+ r.fail(&name, "minisig content differs from upstream");
+ } else {
+ r.ok(&name);
+ }
+ }
+ (Err(e), _) | (_, Err(e)) => r.fail(&name, &e),
+ }
+ }
+ }
+
+ {
+ let name = "zig: nonexistent version returns 404";
+ match r.fetch_status(&format!("{base_url}/zig/zig-x86_64-linux-99.99.99.tar.xz")) {
+ Ok(404) => r.ok(name),
+ Ok(s) => r.fail(name, &format!("status {s}, expected 404")),
+ Err(e) => r.fail(name, &e),
+ }
+ }
+}
+
+fn main() {
+ let base_url = match std::env::var("ZORIAN_URL") {
+ Ok(url) => url,
+ Err(_) => {
+ eprintln!("Error: ZORIAN_URL environment variable is not set.");
+ eprintln!("Usage: ZORIAN_URL=https://pkg.earth cargo run -p smoke");
+ std::process::exit(1);
+ }
+ };
+
+ let base_url = base_url.trim_end_matches('/');
+ println!("Smoke tests against {base_url}");
+
+ let mut runner = Runner::new();
+
+ test_web(&mut runner, base_url);
+ test_go(&mut runner, base_url);
+ test_zig(&mut runner, base_url);
+
+ println!(
+ "\n--- Results: {} passed, {} failed ---",
+ runner.passed, runner.failed
+ );
+
+ if runner.failed > 0 {
+ std::process::exit(1);
+ }
+}