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.4 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 /// Base URL advertised in clone URLs. An agent cannot clone a bare path, so
404 /// this must be an origin even when nothing is configured.
405 pub fn public_host(&self) -> String {
406 brand::env("PUBLIC_URL")
407 .map(|v| v.trim_end_matches('/').to_string())
408 .unwrap_or_else(|| format!("http://{}", self.bind))
409 }
410
411 /// Base advertised for SSH clones, as an `ssh://` URL.
412 ///
413 /// The scheme form rather than scp-style `host:path`, because the port has to
414 /// be expressible and `host:2222:alice/site.git` is not a thing git parses.
415 pub fn public_ssh_base(&self) -> Option<String> {
416 if let Some(configured) = brand::env("PUBLIC_SSH") {
417 return Some(configured.trim_end_matches('/').to_string());
418 }
419 self.ssh_bind.map(|addr| format!("ssh://git@{addr}"))
420 }
421}
422
423/// Defaults, in one block so they are reviewable without reading the parser.
424const DEFAULT_BIND: &str = "127.0.0.1:8790";
425const DEFAULT_SSH_BIND: &str = "127.0.0.1:2222";
426const DEFAULT_MODE: &str = "standalone";
427const DEFAULT_DISK_RESERVE_MB: u64 = 2048;
428const DEFAULT_MAX_BODY_MB: u64 = 10;
429const DEFAULT_MAX_PACK_MB: u64 = 512;
430const DEFAULT_MAX_BLOB_MB: u64 = 32;
431const DEFAULT_MAX_CONCURRENT_GIT: u64 = 8;
432const DEFAULT_PAGE_LIMIT: u64 = 50;
433const DEFAULT_MAX_PAGE_LIMIT: u64 = 200;
434const DEFAULT_HEADER_TIMEOUT_SECS: u64 = 15;
435const DEFAULT_SSH_IDLE_SECS: u64 = 600;
436const DEFAULT_TOKEN_TTL_DAYS: u64 = 90;
437const DEFAULT_LOG_WALK_BUDGET: u64 = 20_000;
438const DEFAULT_MAX_REPOS: u64 = 100;
439const DEFAULT_MAX_REPO_MB: u64 = 2048;
440const DEFAULT_MAX_ACCOUNT_MB: u64 = 10240;
441const DEFAULT_RATE_PER_MINUTE: u64 = 600;
442const DEFAULT_GC_INTERVAL_SECS: u64 = 6 * 60 * 60;
443/// Objects younger than this are never pruned.
444///
445/// This grace window is what makes scheduled GC safe against a concurrent clone:
446/// `upload-pack` may reference an object between resolving it and streaming it, and
447/// pruning inside that window is the classic way to hand a client a corrupt clone.
448/// Two weeks is git's own default for the same reason.
449const DEFAULT_GC_PRUNE_GRACE: &str = "2.weeks.ago";
450/// How long a soft-deleted repository stays recoverable.
451const DEFAULT_DELETED_RETENTION_DAYS: u64 = 7;
452const DEFAULT_CI_TIMEOUT_SECS: u64 = 600;
453const DEFAULT_CI_MAX_CONCURRENT: u64 = 2;
454const DEFAULT_CI_LOG_MB: u64 = 8;
455const DEFAULT_CI_MEMORY_MB: u64 = 2048;
456/// Generous, because this bounds a single file and a linker writing a binary with
457/// debug information routinely passes a few hundred megabytes. It is here to stop a
458/// step filling the disk, not to be tight.
459const DEFAULT_CI_FILE_MB: u64 = 2048;
460/// Off by default, and this is not timidity.
461///
462/// `RLIMIT_NPROC` is per-UID, not per-process. With the runner sharing a uid with
463/// the service — which is the standalone case — the limit counts every process the
464/// service user already owns, so a small value makes `fork` fail immediately and a
465/// large one bounds nothing. It is only meaningful once the operator gives the
466/// runner its own uid, which is also what makes it safe.
467const DEFAULT_CI_MAX_PROCESSES: u64 = 0;
468/// Runs kept per repository. Older ones are swept with their logs.
469const DEFAULT_CI_KEEP_RUNS: u64 = 50;
470const DEFAULT_IMPORT_TIMEOUT_SECS: u64 = 300;
471const DEFAULT_INCUS_IMAGE: &str = "images:debian/12";
472const DEFAULT_INCUS_NETWORK: &str = "incusbr0";
473const DEFAULT_TENANT_PORT: u64 = 8790;
474const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 300;
475
476fn env_or(key: &str, default: &str) -> String {
477 brand::env(key).unwrap_or_else(|| default.to_string())
478}
479
480fn env_bool(key: &str, default: bool) -> Result<bool> {
481 match brand::env(key) {
482 None => Ok(default),
483 Some(v) => match v.as_str() {
484 "1" | "true" | "yes" => Ok(true),
485 "0" | "false" | "no" => Ok(false),
486 other => Err(anyhow!(
487 "{} must be a boolean, got {other:?}",
488 brand::env_name(key)
489 )),
490 },
491 }
492}
493
494fn env_u64(key: &str, default: u64) -> Result<u64> {
495 match brand::env(key) {
496 None => Ok(default),
497 Some(v) => v.parse().with_context(|| {
498 format!(
499 "{} must be a non-negative integer, got {v:?}",
500 brand::env_name(key)
501 )
502 }),
503 }
504}
505
506/// A byte count given in megabytes.
507fn env_mb(key: &str, default_mb: u64) -> Result<u64> {
508 Ok(env_u64(key, default_mb)? * 1024 * 1024)
509}
510
511fn env_secs(key: &str, default: u64) -> Result<Duration> {
512 Ok(Duration::from_secs(env_u64(key, default)?))
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
520 fn mode_parses_the_three_documented_values_and_rejects_others() {
521 assert_eq!("standalone".parse::<Mode>().unwrap(), Mode::Standalone);
522 assert_eq!("control".parse::<Mode>().unwrap(), Mode::Control);
523 assert_eq!("tenant".parse::<Mode>().unwrap(), Mode::Tenant);
524 assert!("production".parse::<Mode>().is_err());
525 }
526
527 #[test]
528 fn the_file_size_limit_does_not_depend_on_the_memory_limit() {
529 // These were once one value: the file limit was derived as
530 // max(log_bytes * 4, memory). Turning the memory limit off to let a
531 // JavaScript toolchain reserve address space therefore also shrank the file
532 // limit to 32MB, and the linker died with SIGXFSZ mid-build. A limit that
533 // moves when an unrelated limit is changed is a trap, so they are separate
534 // settings with separate defaults and neither may be computed from the other.
535 assert!(DEFAULT_CI_FILE_MB > 0);
536 assert!(
537 DEFAULT_CI_FILE_MB >= DEFAULT_CI_LOG_MB,
538 "a step must be able to write its own log"
539 );
540 }
541
542 #[test]
543 fn env_bool_accepts_documented_spellings_and_rejects_ambiguity() {
544 std::env::set_var(brand::env_name("TEST_BOOL"), "yes");
545 assert!(env_bool("TEST_BOOL", false).unwrap());
546 std::env::set_var(brand::env_name("TEST_BOOL"), "0");
547 assert!(!env_bool("TEST_BOOL", true).unwrap());
548 std::env::set_var(brand::env_name("TEST_BOOL"), "maybe");
549 assert!(env_bool("TEST_BOOL", false).is_err());
550 std::env::remove_var(brand::env_name("TEST_BOOL"));
551 }
552
553 #[test]
554 fn env_u64_rejects_a_non_numeric_value_rather_than_falling_back() {
555 std::env::set_var(brand::env_name("TEST_U64"), "lots");
556 assert!(env_u64("TEST_U64", 1).is_err());
557 std::env::remove_var(brand::env_name("TEST_U64"));
558 }
559
560 #[test]
561 fn derived_paths_all_hang_off_the_data_dir() {
562 let config = Config {
563 bind: "127.0.0.1:8790".parse().unwrap(),
564 mode: Mode::Standalone,
565 data_dir: PathBuf::from("/srv/data"),
566 me_url: None,
567 ci_enabled: false,
568 disk_reserve_bytes: 0,
569 max_body_bytes: 0,
570 max_pack_bytes: 0,
571 max_blob_bytes: 0,
572 max_concurrent_git: 1,
573 default_page_limit: 50,
574 max_page_limit: 200,
575 header_timeout: Duration::from_secs(15),
576 ssh_idle_timeout: Duration::from_secs(600),
577 token_ttl_days: 90,
578 log_walk_budget: 1000,
579 max_repos_per_account: 100,
580 max_repo_bytes: 0,
581 max_account_bytes: 0,
582 rate_per_minute: 0,
583 gc_interval: Duration::from_secs(3600),
584 gc_prune_grace: "2.weeks.ago".into(),
585 deleted_retention: Duration::from_secs(604_800),
586 ci_timeout: Duration::from_secs(600),
587 ci_max_concurrent: 2,
588 ci_log_bytes: 1024 * 1024,
589 ci_memory_bytes: 0,
590 ci_file_bytes: 0,
591 ci_max_processes: 0,
592 ci_keep_runs: 50,
593 import_enabled: true,
594 import_timeout: Duration::from_secs(300),
595 import_allow_private: false,
596 control_key_file: PathBuf::from("/srv/data/control_ed25519_seed"),
597 control_public_keys: Vec::new(),
598 incus_image: "images:debian/12".into(),
599 incus_network: "incusbr0".into(),
600 tenant_port: 8790,
601 tenant_binary: format!("/usr/local/bin/{}", brand::NAME),
602 proxy_timeout: Duration::from_secs(300),
603 ssh_bind: None,
604 ssh_host_key: PathBuf::from("/srv/data/ssh_host_ed25519_key"),
605 };
606 assert_eq!(config.tokens_file(), PathBuf::from("/srv/data/tokens.json"));
607 assert_eq!(config.meta_dir(), PathBuf::from("/srv/data/meta"));
608 }
609}