zuka
zuka/src/config.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/config.rs
RSconfig.rs23.9 KBDownload
1// Boot configuration. Read once, validated once, failing loudly — a bad value must
2// stop the process rather than surface as a confusing 500 on the first request.
3
4use crate::brand;
5use anyhow::{anyhow, bail, Context, Result};
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::time::Duration;
9
10/// Deployment shape. `Standalone` and `Tenant` share the request-handling path and
11/// differ in how `Identity` resolves and how CI is isolated (SPEC §1.1).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14 Standalone,
15 Control,
16 Tenant,
17}
18
19impl std::str::FromStr for Mode {
20 type Err = anyhow::Error;
21
22 fn from_str(s: &str) -> Result<Self> {
23 match s {
24 "standalone" => Ok(Mode::Standalone),
25 "control" => Ok(Mode::Control),
26 "tenant" => Ok(Mode::Tenant),
27 other => bail!(
28 "unknown {} {other:?} (standalone | control | tenant)",
29 brand::env_name("MODE")
30 ),
31 }
32 }
33}
34
35impl Mode {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Mode::Standalone => "standalone",
39 Mode::Control => "control",
40 Mode::Tenant => "tenant",
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
46pub struct Config {
47 pub bind: SocketAddr,
48 pub mode: Mode,
49 pub data_dir: PathBuf,
50 /// Opt-in hosted auth. Deliberately has no default: a self-hoster who configures
51 /// nothing must not get a binary that phones Worklyn to authenticate and 502s
52 /// when it cannot reach it (SPEC §10).
53 pub me_url: Option<String>,
54 pub ci_enabled: bool,
55 /// Writes are refused with 507 below this, because receive-pack interrupted by
56 /// ENOSPC leaves a partial pack (SPEC §4.9).
57 pub disk_reserve_bytes: u64,
58 pub max_body_bytes: u64,
59 /// Largest pack accepted on a push.
60 pub max_pack_bytes: u64,
61 /// Largest blob served inline by the read API.
62 pub max_blob_bytes: u64,
63 /// Ceiling on concurrent `git` subprocesses.
64 pub max_concurrent_git: usize,
65 pub default_page_limit: usize,
66 pub max_page_limit: usize,
67 /// How long a connection may go without sending a request line.
68 pub header_timeout: Duration,
69 pub ssh_idle_timeout: Duration,
70 /// Default token lifetime, in days.
71 pub token_ttl_days: u64,
72 /// How far a path-filtered log may walk before reporting truncation.
73 pub log_walk_budget: usize,
74 /// Repositories one account may own. `0` disables the check.
75 pub max_repos_per_account: u64,
76 /// Bytes one repository may occupy. `0` disables the check.
77 pub max_repo_bytes: u64,
78 /// Bytes one account may occupy across all repositories. `0` disables.
79 pub max_account_bytes: u64,
80 /// Requests per minute per credential. `0` disables rate limiting.
81 pub rate_per_minute: u32,
82 /// How often the jobs process runs maintenance.
83 pub gc_interval: Duration,
84 /// Passed to `git prune --expire`.
85 pub gc_prune_grace: String,
86 /// How long a soft-deleted repository is retained.
87 pub deleted_retention: Duration,
88 /// Ceiling on a single run's wall clock. A spec may lower it, never raise it.
89 pub ci_timeout: Duration,
90 /// Runs executing at once on this host.
91 pub ci_max_concurrent: usize,
92 /// Cap on one run's captured output.
93 pub ci_log_bytes: u64,
94 /// `RLIMIT_AS` for a step. `0` leaves address space unlimited.
95 pub ci_memory_bytes: u64,
96 /// Largest file a CI step may write. 0 disables the limit.
97 pub ci_file_bytes: u64,
98 /// `RLIMIT_NPROC` for a step. `0` disables it, which is the default — the limit
99 /// is per-UID and only means anything with a dedicated runner uid.
100 pub ci_max_processes: u64,
101 /// Runs retained per repository.
102 pub ci_keep_runs: usize,
103 /// Whether repositories may be imported from a remote URL.
104 pub import_enabled: bool,
105 /// Ceiling on how long an import may take.
106 pub import_timeout: Duration,
107 /// Control plane: Ed25519 seed used to sign tenant assertions, base64.
108 ///
109 /// Generated on first boot if absent. Never leaves the control plane.
110 pub control_key_file: PathBuf,
111 /// Tenant: base64 public keys that may sign assertions. More than one so the
112 /// control plane's key can be rotated without a flag day.
113 pub control_public_keys: Vec<String>,
114 /// Control plane: Incus image for a new tenant.
115 pub incus_image: String,
116 /// Control plane: Incus network a tenant joins.
117 pub incus_network: String,
118 /// Port a tenant listens on inside its container.
119 pub tenant_port: u16,
120 /// Control plane: path to the binary pushed into a tenant.
121 pub tenant_binary: String,
122 /// Control plane: ceiling on one proxied request.
123 pub proxy_timeout: Duration,
124 /// Permit importing from private and loopback addresses.
125 ///
126 /// Needed on an internal network; a hole on a public one, which is why it is
127 /// off by default and logged loudly when on.
128 pub import_allow_private: bool,
129 /// SSH listener. `None` disables the SSH transport.
130 pub ssh_bind: Option<SocketAddr>,
131 /// Ed25519 host key. Generated on first boot if absent.
132 pub ssh_host_key: PathBuf,
133}
134
135impl Config {
136 pub fn from_env() -> Result<Self> {
137 let bind_raw = env_or("BIND", DEFAULT_BIND);
138 let bind: SocketAddr = bind_raw.parse().with_context(|| {
139 format!(
140 "{} {bind_raw:?} is not a socket address",
141 brand::env_name("BIND")
142 )
143 })?;
144
145 let mode: Mode = env_or("MODE", DEFAULT_MODE).parse()?;
146
147 let data_dir = PathBuf::from(env_or("DATA_DIR", brand::default_data_dir()));
148 if data_dir.is_relative() {
149 bail!(
150 "{} must be absolute, got {}",
151 brand::env_name("DATA_DIR"),
152 data_dir.display()
153 );
154 }
155
156 let me_url = brand::env("ME_URL").and_then(|v| {
157 let trimmed = v.trim_end_matches('/').to_string();
158 if trimmed.is_empty() {
159 None
160 } else {
161 Some(trimmed)
162 }
163 });
164 if let Some(url) = &me_url {
165 if !url.starts_with("https://") && !url.starts_with("http://127.0.0.1") {
166 bail!(
167 "{} must be https, or http on loopback for development",
168 brand::env_name("ME_URL")
169 );
170 }
171 }
172
173 let ci_enabled = env_bool("CI_ENABLED", false)?;
174
175 let disk_reserve_bytes = env_mb("DISK_RESERVE_MB", DEFAULT_DISK_RESERVE_MB)?;
176 let max_body_bytes = env_mb("MAX_BODY_MB", DEFAULT_MAX_BODY_MB)?;
177 let max_pack_bytes = env_mb("MAX_PACK_MB", DEFAULT_MAX_PACK_MB)?;
178 let max_blob_bytes = env_mb("MAX_BLOB_MB", DEFAULT_MAX_BLOB_MB)?;
179
180 let default_page_limit = env_u64("PAGE_LIMIT", DEFAULT_PAGE_LIMIT)? as usize;
181 let max_page_limit = env_u64("MAX_PAGE_LIMIT", DEFAULT_MAX_PAGE_LIMIT)? as usize;
182 if default_page_limit == 0 || default_page_limit > max_page_limit {
183 bail!(
184 "{} must be between 1 and {}",
185 brand::env_name("PAGE_LIMIT"),
186 brand::env_name("MAX_PAGE_LIMIT")
187 );
188 }
189
190 let header_timeout = env_secs("HEADER_TIMEOUT_SECS", DEFAULT_HEADER_TIMEOUT_SECS)?;
191 let ssh_idle_timeout = env_secs("SSH_IDLE_SECS", DEFAULT_SSH_IDLE_SECS)?;
192 let token_ttl_days = env_u64("TOKEN_TTL_DAYS", DEFAULT_TOKEN_TTL_DAYS)?;
193 let log_walk_budget = env_u64("LOG_WALK_BUDGET", DEFAULT_LOG_WALK_BUDGET)? as usize;
194
195 let max_repos_per_account = env_u64("MAX_REPOS_PER_ACCOUNT", DEFAULT_MAX_REPOS)?;
196 let max_repo_bytes = env_mb("MAX_REPO_MB", DEFAULT_MAX_REPO_MB)?;
197 let max_account_bytes = env_mb("MAX_ACCOUNT_MB", DEFAULT_MAX_ACCOUNT_MB)?;
198 if max_repo_bytes > 0 && max_account_bytes > 0 && max_repo_bytes > max_account_bytes {
199 bail!(
200 "{} must not exceed {}",
201 brand::env_name("MAX_REPO_MB"),
202 brand::env_name("MAX_ACCOUNT_MB")
203 );
204 }
205
206 let rate_per_minute = env_u64("RATE_PER_MINUTE", DEFAULT_RATE_PER_MINUTE)? as u32;
207 let gc_interval = env_secs("GC_INTERVAL_SECS", DEFAULT_GC_INTERVAL_SECS)?;
208 let gc_prune_grace = env_or("GC_PRUNE_GRACE", DEFAULT_GC_PRUNE_GRACE);
209 let deleted_retention = Duration::from_secs(
210 env_u64("DELETED_RETENTION_DAYS", DEFAULT_DELETED_RETENTION_DAYS)? * 24 * 60 * 60,
211 );
212
213 let import_enabled = env_bool("IMPORT_ENABLED", true)?;
214 // Off by default. A self-hoster whose git server is on a private network
215 // genuinely needs this; on a public host it re-opens SSRF, so it is opt-in
216 // and announced at boot rather than quietly available.
217 let import_allow_private = env_bool("IMPORT_ALLOW_PRIVATE", false)?;
218 let import_timeout = env_secs("IMPORT_TIMEOUT_SECS", DEFAULT_IMPORT_TIMEOUT_SECS)?;
219
220 let control_key_file = data_dir.join("control_ed25519_seed");
221 let control_public_keys = brand::env("CONTROL_PUBLIC_KEYS")
222 .map(|v| {
223 v.split(',')
224 .map(str::trim)
225 .filter(|s| !s.is_empty())
226 .map(String::from)
227 .collect::<Vec<_>>()
228 })
229 .unwrap_or_default();
230 if mode == Mode::Tenant && control_public_keys.is_empty() {
231 bail!(
232 "{} is required in tenant mode: a tenant must know which key may \
233 vouch for a caller",
234 brand::env_name("CONTROL_PUBLIC_KEYS")
235 );
236 }
237
238 let incus_image = env_or("INCUS_IMAGE", DEFAULT_INCUS_IMAGE);
239 let incus_network = env_or("INCUS_NETWORK", DEFAULT_INCUS_NETWORK);
240 let tenant_port = env_u64("TENANT_PORT", DEFAULT_TENANT_PORT)? as u16;
241 let tenant_binary = env_or(
242 "TENANT_BINARY",
243 &std::env::current_exe()
244 .map(|p| p.display().to_string())
245 .unwrap_or_else(|_| format!("/usr/local/bin/{}", brand::NAME)),
246 );
247 let proxy_timeout = env_secs("PROXY_TIMEOUT_SECS", DEFAULT_PROXY_TIMEOUT_SECS)?;
248
249 let ci_timeout = env_secs("CI_TIMEOUT_SECS", DEFAULT_CI_TIMEOUT_SECS)?;
250 let ci_max_concurrent = env_u64("CI_MAX_CONCURRENT", DEFAULT_CI_MAX_CONCURRENT)? as usize;
251 let ci_log_bytes = env_mb("CI_LOG_MB", DEFAULT_CI_LOG_MB)?;
252 let ci_memory_bytes = env_mb("CI_MEMORY_MB", DEFAULT_CI_MEMORY_MB)?;
253 let ci_file_bytes = env_mb("CI_FILE_MB", DEFAULT_CI_FILE_MB)?;
254 let ci_max_processes = env_u64("CI_MAX_PROCESSES", DEFAULT_CI_MAX_PROCESSES)?;
255 let ci_keep_runs = env_u64("CI_KEEP_RUNS", DEFAULT_CI_KEEP_RUNS)? as usize;
256 if ci_enabled && ci_max_concurrent == 0 {
257 bail!(
258 "{} must be at least 1",
259 brand::env_name("CI_MAX_CONCURRENT")
260 );
261 }
262
263 let max_concurrent_git =
264 env_u64("MAX_CONCURRENT_GIT", DEFAULT_MAX_CONCURRENT_GIT)? as usize;
265 if max_concurrent_git == 0 {
266 bail!(
267 "{} must be at least 1",
268 brand::env_name("MAX_CONCURRENT_GIT")
269 );
270 }
271
272 let ssh_bind = match brand::env("SSH_BIND").ok_or(()) {
273 Err(()) => Some(
274 DEFAULT_SSH_BIND
275 .parse()
276 .expect("literal is a valid address"),
277 ),
278 Ok(v) if v.is_empty() || v == "off" => None,
279 Ok(v) => Some(v.parse().with_context(|| {
280 format!(
281 "{} {v:?} is not a socket address",
282 brand::env_name("SSH_BIND")
283 )
284 })?),
285 };
286 let ssh_host_key = data_dir.join("ssh_host_ed25519_key");
287
288 Ok(Config {
289 bind,
290 mode,
291 data_dir,
292 me_url,
293 ci_enabled,
294 disk_reserve_bytes,
295 max_body_bytes,
296 max_pack_bytes,
297 max_blob_bytes,
298 max_concurrent_git,
299 default_page_limit,
300 max_page_limit,
301 header_timeout,
302 ssh_idle_timeout,
303 token_ttl_days,
304 log_walk_budget,
305 max_repos_per_account,
306 max_repo_bytes,
307 max_account_bytes,
308 rate_per_minute,
309 gc_interval,
310 gc_prune_grace,
311 deleted_retention,
312 ci_timeout,
313 ci_max_concurrent,
314 ci_log_bytes,
315 ci_memory_bytes,
316 ci_file_bytes,
317 ci_max_processes,
318 ci_keep_runs,
319 import_enabled,
320 import_timeout,
321 import_allow_private,
322 control_key_file,
323 control_public_keys,
324 incus_image,
325 incus_network,
326 tenant_port,
327 tenant_binary,
328 proxy_timeout,
329 ssh_bind,
330 ssh_host_key,
331 })
332 }
333
334 pub fn tokens_file(&self) -> PathBuf {
335 self.data_dir.join("tokens.json")
336 }
337
338 pub fn meta_dir(&self) -> PathBuf {
339 self.data_dir.join("meta")
340 }
341
342 /// Environment a git child must pass down so our hooks can read configuration.
343 ///
344 /// Both spawn sites clear the environment — deliberately, so a client cannot
345 /// inject one — which also means a hook inherits nothing. Without this the
346 /// `post-receive` hook resolves the default data directory and silently queues
347 /// runs into the wrong place, or nowhere.
348 pub fn hook_env(&self) -> Vec<(String, String)> {
349 vec![
350 (
351 brand::env_name("DATA_DIR"),
352 self.data_dir.display().to_string(),
353 ),
354 (
355 brand::env_name("CI_ENABLED"),
356 if self.ci_enabled { "1" } else { "0" }.to_string(),
357 ),
358 ]
359 }
360
361 /// Read-path bounds, as `git::discover` needs them.
362 ///
363 /// A projection of configuration, so both facades bound reads identically
364 /// rather than one reaching into the other for it.
365 pub fn read_limits(&self) -> crate::git::discover::Limits {
366 crate::git::discover::Limits {
367 max_blob_bytes: self.max_blob_bytes,
368 log_walk_budget: self.log_walk_budget,
369 }
370 }
371
372 /// Validate a requested page size against the configured bounds.
373 ///
374 /// Shared by REST and MCP so a caller gets the same answer whichever surface
375 /// they ask through.
376 pub fn page_limit(&self, requested: Option<usize>) -> crate::error::Result<usize> {
377 match requested {
378 None => Ok(self.default_page_limit),
379 Some(n) if n == 0 || n > self.max_page_limit => Err(crate::error::Error::invalid(
380 "invalid-query",
381 format!("limit must be between 1 and {}", self.max_page_limit),
382 )),
383 Some(n) => Ok(n),
384 }
385 }
386
387 pub fn accounts_dir(&self) -> PathBuf {
388 self.data_dir.join("accounts")
389 }
390
391 pub fn runs_dir(&self) -> PathBuf {
392 self.data_dir.join("runs")
393 }
394
395 pub fn deleted_dir(&self) -> PathBuf {
396 self.data_dir.join("tmp").join("deleted")
397 }
398
399 pub fn keys_file(&self) -> PathBuf {
400 self.data_dir.join("ssh_keys.json")
401 }
402
403 /// The repository featured at `/` in the browser, as `(account, repo)`.
404 ///
405 /// Optional: a host that names none serves an explanation at `/` rather than a
406 /// 404. Visibility still applies — naming a private repository here does not
407 /// publish it, it just means anonymous visitors see nothing.
408 pub fn home_repo(&self) -> Option<(String, String)> {
409 let value = std::env::var(crate::brand::env_name("HOME_REPO")).ok()?;
410 let (account, repo) = value.split_once('/')?;
411 if account.is_empty() || repo.is_empty() {
412 return None;
413 }
414 Some((account.to_string(), repo.to_string()))
415 }
416
417 /// Base URL advertised in clone URLs. An agent cannot clone a bare path, so
418 /// this must be an origin even when nothing is configured.
419 pub fn public_host(&self) -> String {
420 brand::env("PUBLIC_URL")
421 .map(|v| v.trim_end_matches('/').to_string())
422 .unwrap_or_else(|| format!("http://{}", self.bind))
423 }
424
425 /// Base advertised for SSH clones, as an `ssh://` URL.
426 ///
427 /// The scheme form rather than scp-style `host:path`, because the port has to
428 /// be expressible and `host:2222:alice/site.git` is not a thing git parses.
429 pub fn public_ssh_base(&self) -> Option<String> {
430 if let Some(configured) = brand::env("PUBLIC_SSH") {
431 return Some(configured.trim_end_matches('/').to_string());
432 }
433 self.ssh_bind.map(|addr| format!("ssh://git@{addr}"))
434 }
435}
436
437/// Defaults, in one block so they are reviewable without reading the parser.
438const DEFAULT_BIND: &str = "127.0.0.1:8790";
439const DEFAULT_SSH_BIND: &str = "127.0.0.1:2222";
440const DEFAULT_MODE: &str = "standalone";
441const DEFAULT_DISK_RESERVE_MB: u64 = 2048;
442const DEFAULT_MAX_BODY_MB: u64 = 10;
443const DEFAULT_MAX_PACK_MB: u64 = 512;
444const DEFAULT_MAX_BLOB_MB: u64 = 32;
445const DEFAULT_MAX_CONCURRENT_GIT: u64 = 8;
446const DEFAULT_PAGE_LIMIT: u64 = 50;
447const DEFAULT_MAX_PAGE_LIMIT: u64 = 200;
448const DEFAULT_HEADER_TIMEOUT_SECS: u64 = 15;
449const DEFAULT_SSH_IDLE_SECS: u64 = 600;
450const DEFAULT_TOKEN_TTL_DAYS: u64 = 90;
451const DEFAULT_LOG_WALK_BUDGET: u64 = 20_000;
452const DEFAULT_MAX_REPOS: u64 = 100;
453const DEFAULT_MAX_REPO_MB: u64 = 2048;
454const DEFAULT_MAX_ACCOUNT_MB: u64 = 10240;
455const DEFAULT_RATE_PER_MINUTE: u64 = 600;
456const DEFAULT_GC_INTERVAL_SECS: u64 = 6 * 60 * 60;
457/// Objects younger than this are never pruned.
458///
459/// This grace window is what makes scheduled GC safe against a concurrent clone:
460/// `upload-pack` may reference an object between resolving it and streaming it, and
461/// pruning inside that window is the classic way to hand a client a corrupt clone.
462/// Two weeks is git's own default for the same reason.
463const DEFAULT_GC_PRUNE_GRACE: &str = "2.weeks.ago";
464/// How long a soft-deleted repository stays recoverable.
465const DEFAULT_DELETED_RETENTION_DAYS: u64 = 7;
466const DEFAULT_CI_TIMEOUT_SECS: u64 = 600;
467const DEFAULT_CI_MAX_CONCURRENT: u64 = 2;
468const DEFAULT_CI_LOG_MB: u64 = 8;
469const DEFAULT_CI_MEMORY_MB: u64 = 2048;
470/// Generous, because this bounds a single file and a linker writing a binary with
471/// debug information routinely passes a few hundred megabytes. It is here to stop a
472/// step filling the disk, not to be tight.
473const DEFAULT_CI_FILE_MB: u64 = 2048;
474
475// The file limit and the memory limit were once one value: RLIMIT_FSIZE was derived
476// as max(log_bytes * 4, memory). Turning the memory limit off — needed so a
477// JavaScript toolchain can reserve its address space — shrank the file limit to 32MB
478// and the linker was killed by SIGXFSZ mid-build, an error naming neither limit.
479// They are separate settings now, and neither may be computed from the other. These
480// hold at compile time, so a bad default cannot reach a host.
481const _: () = assert!(DEFAULT_CI_FILE_MB > 0);
482const _: () = assert!(
483 DEFAULT_CI_FILE_MB >= DEFAULT_CI_LOG_MB,
484 "a step must be able to write its own log"
485);
486/// Off by default, and this is not timidity.
487///
488/// `RLIMIT_NPROC` is per-UID, not per-process. With the runner sharing a uid with
489/// the service — which is the standalone case — the limit counts every process the
490/// service user already owns, so a small value makes `fork` fail immediately and a
491/// large one bounds nothing. It is only meaningful once the operator gives the
492/// runner its own uid, which is also what makes it safe.
493const DEFAULT_CI_MAX_PROCESSES: u64 = 0;
494/// Runs kept per repository. Older ones are swept with their logs.
495const DEFAULT_CI_KEEP_RUNS: u64 = 50;
496const DEFAULT_IMPORT_TIMEOUT_SECS: u64 = 300;
497const DEFAULT_INCUS_IMAGE: &str = "images:debian/12";
498const DEFAULT_INCUS_NETWORK: &str = "incusbr0";
499const DEFAULT_TENANT_PORT: u64 = 8790;
500const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 300;
501
502fn env_or(key: &str, default: &str) -> String {
503 brand::env(key).unwrap_or_else(|| default.to_string())
504}
505
506fn env_bool(key: &str, default: bool) -> Result<bool> {
507 match brand::env(key) {
508 None => Ok(default),
509 Some(v) => match v.as_str() {
510 "1" | "true" | "yes" => Ok(true),
511 "0" | "false" | "no" => Ok(false),
512 other => Err(anyhow!(
513 "{} must be a boolean, got {other:?}",
514 brand::env_name(key)
515 )),
516 },
517 }
518}
519
520fn env_u64(key: &str, default: u64) -> Result<u64> {
521 match brand::env(key) {
522 None => Ok(default),
523 Some(v) => v.parse().with_context(|| {
524 format!(
525 "{} must be a non-negative integer, got {v:?}",
526 brand::env_name(key)
527 )
528 }),
529 }
530}
531
532/// A byte count given in megabytes.
533fn env_mb(key: &str, default_mb: u64) -> Result<u64> {
534 Ok(env_u64(key, default_mb)? * 1024 * 1024)
535}
536
537fn env_secs(key: &str, default: u64) -> Result<Duration> {
538 Ok(Duration::from_secs(env_u64(key, default)?))
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn mode_parses_the_three_documented_values_and_rejects_others() {
547 assert_eq!("standalone".parse::<Mode>().unwrap(), Mode::Standalone);
548 assert_eq!("control".parse::<Mode>().unwrap(), Mode::Control);
549 assert_eq!("tenant".parse::<Mode>().unwrap(), Mode::Tenant);
550 assert!("production".parse::<Mode>().is_err());
551 }
552
553 #[test]
554 fn env_bool_accepts_documented_spellings_and_rejects_ambiguity() {
555 std::env::set_var(brand::env_name("TEST_BOOL"), "yes");
556 assert!(env_bool("TEST_BOOL", false).unwrap());
557 std::env::set_var(brand::env_name("TEST_BOOL"), "0");
558 assert!(!env_bool("TEST_BOOL", true).unwrap());
559 std::env::set_var(brand::env_name("TEST_BOOL"), "maybe");
560 assert!(env_bool("TEST_BOOL", false).is_err());
561 std::env::remove_var(brand::env_name("TEST_BOOL"));
562 }
563
564 #[test]
565 fn env_u64_rejects_a_non_numeric_value_rather_than_falling_back() {
566 std::env::set_var(brand::env_name("TEST_U64"), "lots");
567 assert!(env_u64("TEST_U64", 1).is_err());
568 std::env::remove_var(brand::env_name("TEST_U64"));
569 }
570
571 #[test]
572 fn derived_paths_all_hang_off_the_data_dir() {
573 let config = Config {
574 bind: "127.0.0.1:8790".parse().unwrap(),
575 mode: Mode::Standalone,
576 data_dir: PathBuf::from("/srv/data"),
577 me_url: None,
578 ci_enabled: false,
579 disk_reserve_bytes: 0,
580 max_body_bytes: 0,
581 max_pack_bytes: 0,
582 max_blob_bytes: 0,
583 max_concurrent_git: 1,
584 default_page_limit: 50,
585 max_page_limit: 200,
586 header_timeout: Duration::from_secs(15),
587 ssh_idle_timeout: Duration::from_secs(600),
588 token_ttl_days: 90,
589 log_walk_budget: 1000,
590 max_repos_per_account: 100,
591 max_repo_bytes: 0,
592 max_account_bytes: 0,
593 rate_per_minute: 0,
594 gc_interval: Duration::from_secs(3600),
595 gc_prune_grace: "2.weeks.ago".into(),
596 deleted_retention: Duration::from_secs(604_800),
597 ci_timeout: Duration::from_secs(600),
598 ci_max_concurrent: 2,
599 ci_log_bytes: 1024 * 1024,
600 ci_memory_bytes: 0,
601 ci_file_bytes: 0,
602 ci_max_processes: 0,
603 ci_keep_runs: 50,
604 import_enabled: true,
605 import_timeout: Duration::from_secs(300),
606 import_allow_private: false,
607 control_key_file: PathBuf::from("/srv/data/control_ed25519_seed"),
608 control_public_keys: Vec::new(),
609 incus_image: "images:debian/12".into(),
610 incus_network: "incusbr0".into(),
611 tenant_port: 8790,
612 tenant_binary: format!("/usr/local/bin/{}", brand::NAME),
613 proxy_timeout: Duration::from_secs(300),
614 ssh_bind: None,
615 ssh_host_key: PathBuf::from("/srv/data/ssh_host_ed25519_key"),
616 };
617 assert_eq!(config.tokens_file(), PathBuf::from("/srv/data/tokens.json"));
618 assert_eq!(config.meta_dir(), PathBuf::from("/srv/data/meta"));
619 }
620}