forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
23.3 KiB
1//! Instance configuration, read from TOML.
2
3use std::net::{IpAddr, SocketAddr};
4use std::path::{Path, PathBuf};
5
6use ipnet::IpNet;
7use serde::{Deserialize, Deserializer};
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Registration {
14 Closed,
15 Invite,
16 Open,
17}
18
19/// Parses each entry as a CIDR network or as a single address (a /32 or /128).
20fn deserialize_trusted_proxies<'de, D: Deserializer<'de>>(
21 deserializer: D,
22) -> std::result::Result<Vec<IpNet>, D::Error> {
23 let entries = Vec::<String>::deserialize(deserializer)?;
24 entries
25 .iter()
26 .map(|entry| {
27 if let Ok(net) = entry.parse::<IpNet>() {
28 return Ok(net);
29 }
30 entry
31 .parse::<IpAddr>()
32 .map(IpNet::from)
33 .map_err(|_| serde::de::Error::custom(format!("invalid trusted proxy `{entry}`")))
34 })
35 .collect()
36}
37
38fn default_user_content_listen() -> SocketAddr {
39 "127.0.0.1:7661"
40 .parse()
41 .expect("valid default socket address")
42}
43
44/// Outbound webhook delivery policy. Internal destinations are refused unless listed in `allowed_hosts`.
45#[derive(Debug, Clone, Default, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct WebhookConfig {
48 /// Permit `http://` hook URLs in addition to `https://`.
49 #[serde(default)]
50 pub allow_http: bool,
51 /// Exact hostnames or CIDR networks that may be reached even when they resolve to internal addresses.
52 #[serde(default)]
53 pub allowed_hosts: Vec<String>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct Config {
59 pub database: PathBuf,
60 pub data_dir: PathBuf,
61 pub listen: SocketAddr,
62 pub base_url: String,
63 pub user_content_base_url: String,
64 /// Listener that serves raw user files on the separate user-content origin.
65 #[serde(default = "default_user_content_listen")]
66 pub user_content_listen: SocketAddr,
67 pub ssh_listen: SocketAddr,
68 pub ssh_public_host: String,
69 pub ssh_public_port: u16,
70 pub registration: Registration,
71 /// Peers whose `X-Forwarded-For` header is trusted, such as the reverse proxy in front of forge.
72 /// Entries are addresses or CIDR networks. Empty by default, so the TCP peer is the client.
73 #[serde(default, deserialize_with = "deserialize_trusted_proxies")]
74 pub trusted_proxies: Vec<IpNet>,
75 /// Outbound webhook policy, from the `[webhooks]` section.
76 #[serde(default)]
77 pub webhooks: WebhookConfig,
78 /// Scheduled backups and fsck, from the `[backup]` section. An empty `dir` disables both.
79 #[serde(default)]
80 pub backup: BackupConfig,
81 /// Release asset limits, from the `[releases]` section.
82 #[serde(default)]
83 pub releases: ReleasesConfig,
84 /// Standby replication, from the `[replication]` section. An empty `target` disables it.
85 #[serde(default)]
86 pub replication: ReplicationConfig,
87 /// Git LFS limits, from the `[lfs]` section.
88 #[serde(default)]
89 pub lfs: LfsConfig,
90 /// CI limits and retention, from the `[ci]` section.
91 #[serde(default)]
92 pub ci: CiConfig,
93 /// Security advisories (opt-in), from the `[advisories]` section.
94 #[serde(default)]
95 pub advisories: AdvisoriesConfig,
96 /// Outgoing mail transport, from the `[mail]` section. The SMTP account is set in the database.
97 #[serde(default)]
98 pub mail: MailConfig,
99 /// Organization rules, from the `[orgs]` section.
100 #[serde(default)]
101 pub orgs: OrgsConfig,
102 /// Self-update after a green CI run of the forge's own repo, from the `[self_update]` section.
103 #[serde(default)]
104 pub self_update: SelfUpdateConfig,
105}
106
107/// Self-update, from the `[self_update]` section. When enabled, a successful push run of `repo`
108/// on `branch` replaces the running binary with the `artifact` of `job` and restarts.
109#[derive(Debug, Clone, Deserialize)]
110#[serde(deny_unknown_fields, default)]
111pub struct SelfUpdateConfig {
112 /// Whether a green run deploys its artifact. Off by default.
113 pub enabled: bool,
114 /// The repo whose runs deploy, as `owner/name`.
115 pub repo: String,
116 /// The branch whose push runs deploy.
117 pub branch: String,
118 /// The CI job that produces the artifact.
119 pub job: String,
120 /// The artifact name produced by `job`. A single file. CI flattens paths, so `dist/forge`
121 /// is stored as `dist_forge`.
122 pub artifact: String,
123}
124
125impl Default for SelfUpdateConfig {
126 fn default() -> Self {
127 SelfUpdateConfig {
128 enabled: false,
129 repo: String::new(),
130 branch: "main".into(),
131 job: "build".into(),
132 artifact: "dist_forge".into(),
133 }
134 }
135}
136
137impl SelfUpdateConfig {
138 fn validate(&self) -> Result<()> {
139 if !self.enabled {
140 return Ok(());
141 }
142 let parts: Vec<&str> = self.repo.split('/').collect();
143 if parts.len() != 2 || parts.iter().any(|part| part.is_empty()) {
144 return Err(Error::Config(
145 "[self_update] repo must be owner/name when enabled".into(),
146 ));
147 }
148 for (field, value) in [
149 ("branch", &self.branch),
150 ("job", &self.job),
151 ("artifact", &self.artifact),
152 ] {
153 if value.trim().is_empty() {
154 return Err(Error::Config(format!(
155 "[self_update] {field} must not be empty when enabled"
156 )));
157 }
158 }
159 Ok(())
160 }
161}
162
163/// Organization rules, from the `[orgs]` section.
164#[derive(Debug, Clone, Default, Deserialize)]
165#[serde(deny_unknown_fields, default)]
166pub struct OrgsConfig {
167 /// Who may create an organization: `all` (any signed-in person, the default) or `admin`
168 /// (instance admins only).
169 pub creation: OrgCreation,
170}
171
172/// Who may create organizations. See `OrgsConfig::creation`.
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
174#[serde(rename_all = "lowercase")]
175pub enum OrgCreation {
176 #[default]
177 All,
178 Admin,
179}
180
181/// Outgoing mail transport, from the `[mail]` section. The SMTP server, sender and password are
182/// set on `/admin/mail` and stored in the database; this section only picks the transport.
183#[derive(Debug, Clone, Default, Deserialize)]
184#[serde(deny_unknown_fields, default)]
185pub struct MailConfig {
186 pub transport: MailTransport,
187}
188
189/// `smtp` sends through the configured server. `log` writes each message to `<data_dir>/mail-log/`
190/// instead, for development and tests.
191#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum MailTransport {
194 #[default]
195 Smtp,
196 Log,
197}
198
199/// CI settings, from the `[ci]` section.
200#[derive(Debug, Clone, Deserialize)]
201#[serde(deny_unknown_fields, default)]
202pub struct CiConfig {
203 /// Whether CI runs at all. When false, pushes and pull requests create no runs.
204 pub enabled: bool,
205 /// Largest job log, in MiB. Past it the log is truncated.
206 pub max_log_mb: u64,
207 /// Largest artifact, in MiB.
208 pub max_artifact_mb: u64,
209 /// Artifacts a single job may upload.
210 pub max_artifacts_per_job: u32,
211 /// Days a job log is kept.
212 pub log_retention_days: u64,
213 /// Days an artifact is kept.
214 pub artifact_retention_days: u64,
215 /// Hours a runner registration token stays valid.
216 pub registration_token_hours: u32,
217}
218
219impl Default for CiConfig {
220 fn default() -> Self {
221 CiConfig {
222 enabled: true,
223 max_log_mb: 16,
224 max_artifact_mb: 512,
225 max_artifacts_per_job: 50,
226 log_retention_days: 90,
227 artifact_retention_days: 30,
228 registration_token_hours: 24,
229 }
230 }
231}
232
233/// Security advisories, from the `[advisories]` section. Off by default: when on, the forge
234/// downloads the OSV dumps for `ecosystems` and scans each repo's lockfiles.
235#[derive(Debug, Clone, Deserialize)]
236#[serde(deny_unknown_fields, default)]
237pub struct AdvisoriesConfig {
238 /// Whether the advisory download and the lockfile scans run at all.
239 pub enabled: bool,
240 /// OSV ecosystems to download and scan: any of `crates.io`, `npm`, `Go`.
241 pub ecosystems: Vec<String>,
242 /// Hours before a downloaded dump is fetched again.
243 pub refresh_hours: u64,
244}
245
246impl Default for AdvisoriesConfig {
247 fn default() -> Self {
248 AdvisoriesConfig {
249 enabled: false,
250 ecosystems: vec!["crates.io".into(), "npm".into(), "Go".into()],
251 refresh_hours: 24,
252 }
253 }
254}
255
256/// Git LFS limits, from the `[lfs]` section.
257#[derive(Debug, Clone, Deserialize)]
258#[serde(deny_unknown_fields, default)]
259pub struct LfsConfig {
260 /// Largest accepted LFS object, in MiB.
261 pub max_object_mb: u64,
262}
263
264impl Default for LfsConfig {
265 fn default() -> Self {
266 LfsConfig {
267 max_object_mb: 2048,
268 }
269 }
270}
271
272impl LfsConfig {
273 /// The object size limit in bytes.
274 pub fn max_object_bytes(&self) -> u64 {
275 self.max_object_mb.saturating_mul(1024 * 1024)
276 }
277}
278
279/// Standby replication, from the `[replication]` section. See `docs/backup.md`.
280#[derive(Debug, Clone, Deserialize)]
281#[serde(deny_unknown_fields, default)]
282pub struct ReplicationConfig {
283 /// `[user@]host:/absolute/base` of the standby account. Empty disables replication.
284 pub target: String,
285 /// SSH port of the standby host.
286 pub ssh_port: u16,
287 /// Private key of the standby account (mode 0600). Required when `target` is set.
288 pub identity_file: PathBuf,
289 /// Pinned host keys of the standby. Required when `target` is set; StrictHostKeyChecking is always `yes`.
290 pub known_hosts_file: PathBuf,
291 /// Minutes between database snapshots shipped to the standby.
292 pub db_snapshot_minutes: u64,
293 /// Minutes between reconcile passes (refs checksums, deleted repos, healthcheck).
294 pub reconcile_minutes: u64,
295 /// Optional URL pinged after each reconcile pass. Empty disables it.
296 pub healthcheck_url: String,
297}
298
299impl Default for ReplicationConfig {
300 fn default() -> Self {
301 ReplicationConfig {
302 target: String::new(),
303 ssh_port: 22,
304 identity_file: PathBuf::new(),
305 known_hosts_file: PathBuf::new(),
306 db_snapshot_minutes: 15,
307 reconcile_minutes: 60,
308 healthcheck_url: String::new(),
309 }
310 }
311}
312
313impl ReplicationConfig {
314 /// Whether the replication worker runs at all.
315 pub fn enabled(&self) -> bool {
316 !self.target.is_empty()
317 }
318
319 fn validate(&self) -> Result<()> {
320 if !self.enabled() {
321 return Ok(());
322 }
323 if self.identity_file.as_os_str().is_empty() {
324 return Err(Error::Config(
325 "[replication] identity_file is required when target is set".into(),
326 ));
327 }
328 if self.known_hosts_file.as_os_str().is_empty() {
329 return Err(Error::Config(
330 "[replication] known_hosts_file is required when target is set".into(),
331 ));
332 }
333 if self.db_snapshot_minutes == 0 {
334 return Err(Error::Config(
335 "[replication] db_snapshot_minutes must be at least 1".into(),
336 ));
337 }
338 if self.reconcile_minutes == 0 {
339 return Err(Error::Config(
340 "[replication] reconcile_minutes must be at least 1".into(),
341 ));
342 }
343 Ok(())
344 }
345}
346
347/// Release settings, from the `[releases]` section.
348#[derive(Debug, Clone, Deserialize)]
349#[serde(deny_unknown_fields, default)]
350pub struct ReleasesConfig {
351 /// Largest accepted release asset, in mebibytes.
352 pub max_asset_mb: u64,
353}
354
355impl Default for ReleasesConfig {
356 fn default() -> Self {
357 ReleasesConfig { max_asset_mb: 512 }
358 }
359}
360
361/// Scheduled backups, from the `[backup]` section. See `docs/backup.md`.
362#[derive(Debug, Clone, Deserialize)]
363#[serde(deny_unknown_fields, default)]
364pub struct BackupConfig {
365 /// Directory that receives the backup sets. Empty disables the scheduler.
366 pub dir: PathBuf,
367 /// Hours between full backup sets.
368 pub interval_hours: u64,
369 /// Newest complete sets kept in `dir`. Older complete sets are deleted after a good run.
370 pub keep: usize,
371 /// Optional healthchecks.io style URL. Pinged with `/start`, then the URL itself or `/fail`.
372 pub healthcheck_url: String,
373 /// Days between `git fsck` and integrity checks of every repo and the database.
374 pub fsck_interval_days: u64,
375}
376
377impl Default for BackupConfig {
378 fn default() -> Self {
379 BackupConfig {
380 dir: PathBuf::new(),
381 interval_hours: 24,
382 keep: 7,
383 healthcheck_url: String::new(),
384 fsck_interval_days: 7,
385 }
386 }
387}
388
389impl BackupConfig {
390 /// Whether the scheduler runs at all.
391 pub fn enabled(&self) -> bool {
392 !self.dir.as_os_str().is_empty()
393 }
394
395 fn validate(&self) -> Result<()> {
396 if self.interval_hours == 0 {
397 return Err(Error::Config(
398 "[backup] interval_hours must be at least 1".into(),
399 ));
400 }
401 if self.keep == 0 {
402 return Err(Error::Config("[backup] keep must be at least 1".into()));
403 }
404 if self.fsck_interval_days == 0 {
405 return Err(Error::Config(
406 "[backup] fsck_interval_days must be at least 1".into(),
407 ));
408 }
409 Ok(())
410 }
411}
412
413/// Development defaults, written when the config file is missing. Keep in sync with `config/forge.example.toml`.
414const DEV_DEFAULTS: &str = r#"# forge configuration (development defaults). Production values belong in /etc/forge/forge.toml.
415
416# SQLite/Turso database file.
417database = "./data/forge.db"
418# Root for repos, instance key, gitconfig, hooks and sockets.
419data_dir = "./data"
420# HTTP listen address for the web UI and smart-HTTP.
421listen = "127.0.0.1:7660"
422# Public base URL used in links and clone URLs.
423base_url = "http://127.0.0.1:7660"
424# Separate origin for raw user content (Milestone 2).
425user_content_base_url = "http://127.0.0.1:7661"
426# Listener for raw user content, served on a separate origin.
427user_content_listen = "127.0.0.1:7661"
428# SSH listen address for git over SSH.
429ssh_listen = "0.0.0.0:2222"
430# Host and port shown in SSH clone URLs.
431ssh_public_host = "127.0.0.1"
432ssh_public_port = 2222
433# Self-registration: "closed" | "invite" | "open".
434registration = "closed"
435# Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs.
436trusted_proxies = []
437"#;
438
439impl Config {
440 /// Loads the config from `path`. Writes development defaults if the file is missing.
441 pub fn load(path: &Path) -> Result<Config> {
442 let text = match std::fs::read_to_string(path) {
443 Ok(text) => text,
444 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
445 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
446 std::fs::create_dir_all(parent)?;
447 }
448 std::fs::write(path, DEV_DEFAULTS)?;
449 DEV_DEFAULTS.to_owned()
450 }
451 Err(e) => return Err(e.into()),
452 };
453 let config: Config =
454 toml::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
455 config.backup.validate()?;
456 config.replication.validate()?;
457 config.self_update.validate()?;
458 Ok(config)
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn missing_file_writes_dev_defaults() {
468 let dir = tempfile::tempdir().unwrap();
469 let path = dir.path().join("nested").join("forge.toml");
470 let cfg = Config::load(&path).unwrap();
471 assert!(path.exists());
472 assert_eq!(cfg.database, PathBuf::from("./data/forge.db"));
473 assert_eq!(cfg.listen, "127.0.0.1:7660".parse().unwrap());
474 assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap());
475 assert_eq!(cfg.ssh_listen, "0.0.0.0:2222".parse().unwrap());
476 assert_eq!(cfg.ssh_public_port, 2222);
477 assert_eq!(cfg.registration, Registration::Closed);
478 // Reloading the written file gives the same config.
479 let again = Config::load(&path).unwrap();
480 assert_eq!(again.base_url, cfg.base_url);
481 }
482
483 #[test]
484 fn missing_user_content_listen_uses_default() {
485 let dir = tempfile::tempdir().unwrap();
486 let path = dir.path().join("forge.toml");
487 let text: String = DEV_DEFAULTS
488 .lines()
489 .filter(|line| !line.starts_with("user_content_listen"))
490 .collect::<Vec<_>>()
491 .join("\n");
492 std::fs::write(&path, text).unwrap();
493 let cfg = Config::load(&path).unwrap();
494 assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap());
495 }
496
497 #[test]
498 fn user_content_listen_is_parsed() {
499 let dir = tempfile::tempdir().unwrap();
500 let path = dir.path().join("forge.toml");
501 let text = DEV_DEFAULTS.replace("127.0.0.1:7661\"\n# SSH", "0.0.0.0:9000\"\n# SSH");
502 std::fs::write(&path, text).unwrap();
503 let cfg = Config::load(&path).unwrap();
504 assert_eq!(cfg.user_content_listen, "0.0.0.0:9000".parse().unwrap());
505 }
506
507 #[test]
508 fn unknown_keys_are_rejected() {
509 let dir = tempfile::tempdir().unwrap();
510 let path = dir.path().join("forge.toml");
511 let text = DEV_DEFAULTS.replace("registration", "bogus = 1\nregistration");
512 std::fs::write(&path, text).unwrap();
513 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
514 }
515
516 #[test]
517 fn registration_is_lowercase() {
518 let dir = tempfile::tempdir().unwrap();
519 let path = dir.path().join("forge.toml");
520 std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"invite\"")).unwrap();
521 assert_eq!(
522 Config::load(&path).unwrap().registration,
523 Registration::Invite
524 );
525 std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"Invite\"")).unwrap();
526 assert!(Config::load(&path).is_err());
527 }
528
529 #[test]
530 fn trusted_proxies_accept_addresses_and_networks() {
531 let dir = tempfile::tempdir().unwrap();
532 let path = dir.path().join("forge.toml");
533 let text = DEV_DEFAULTS.replace(
534 "trusted_proxies = []",
535 "trusted_proxies = [\"127.0.0.1\", \"10.0.0.0/8\", \"2001:db8::/32\"]",
536 );
537 std::fs::write(&path, text).unwrap();
538 let cfg = Config::load(&path).unwrap();
539 assert_eq!(cfg.trusted_proxies.len(), 3);
540 assert_eq!(
541 cfg.trusted_proxies[0],
542 "127.0.0.1/32".parse::<IpNet>().unwrap()
543 );
544 assert_eq!(
545 cfg.trusted_proxies[1],
546 "10.0.0.0/8".parse::<IpNet>().unwrap()
547 );
548 }
549
550 #[test]
551 fn invalid_trusted_proxy_is_rejected() {
552 let dir = tempfile::tempdir().unwrap();
553 let path = dir.path().join("forge.toml");
554 let text =
555 DEV_DEFAULTS.replace("trusted_proxies = []", "trusted_proxies = [\"not-an-ip\"]");
556 std::fs::write(&path, text).unwrap();
557 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
558 }
559
560 #[test]
561 fn backup_section_defaults_to_disabled() {
562 let dir = tempfile::tempdir().unwrap();
563 let path = dir.path().join("forge.toml");
564 std::fs::write(&path, DEV_DEFAULTS).unwrap();
565 let cfg = Config::load(&path).unwrap();
566 assert!(!cfg.backup.enabled());
567 assert_eq!(cfg.backup.interval_hours, 24);
568 assert_eq!(cfg.backup.keep, 7);
569 assert_eq!(cfg.backup.fsck_interval_days, 7);
570 assert!(cfg.backup.healthcheck_url.is_empty());
571 }
572
573 #[test]
574 fn replication_section_defaults_to_disabled() {
575 let dir = tempfile::tempdir().unwrap();
576 let path = dir.path().join("forge.toml");
577 std::fs::write(&path, DEV_DEFAULTS).unwrap();
578 let cfg = Config::load(&path).unwrap();
579 assert!(!cfg.replication.enabled());
580 assert_eq!(cfg.replication.ssh_port, 22);
581 assert_eq!(cfg.replication.db_snapshot_minutes, 15);
582 assert_eq!(cfg.replication.reconcile_minutes, 60);
583 assert!(cfg.replication.healthcheck_url.is_empty());
584 }
585
586 #[test]
587 fn replication_target_requires_keys_and_positive_intervals() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("forge.toml");
590 let base = format!("{DEV_DEFAULTS}\n[replication]\ntarget = \"sb@host:/srv/standby\"\n");
591 std::fs::write(&path, &base).unwrap();
592 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
593 std::fs::write(
594 &path,
595 format!("{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\n"),
596 )
597 .unwrap();
598 let cfg = Config::load(&path).unwrap();
599 assert!(cfg.replication.enabled());
600 assert_eq!(cfg.replication.identity_file, PathBuf::from("/k"));
601 std::fs::write(
602 &path,
603 format!(
604 "{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\nreconcile_minutes = 0\n"
605 ),
606 )
607 .unwrap();
608 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
609 }
610
611 #[test]
612 fn self_update_section_defaults_to_disabled() {
613 let dir = tempfile::tempdir().unwrap();
614 let path = dir.path().join("forge.toml");
615 std::fs::write(&path, DEV_DEFAULTS).unwrap();
616 let cfg = Config::load(&path).unwrap();
617 assert!(!cfg.self_update.enabled);
618 assert_eq!(cfg.self_update.branch, "main");
619 assert_eq!(cfg.self_update.job, "build");
620 assert_eq!(cfg.self_update.artifact, "dist_forge");
621 }
622
623 #[test]
624 fn self_update_enabled_requires_owner_name_repo() {
625 let dir = tempfile::tempdir().unwrap();
626 let path = dir.path().join("forge.toml");
627 let base = format!("{DEV_DEFAULTS}\n[self_update]\nenabled = true\n");
628 for repo in ["", "forge", "a/b/c", "/forge", "owner/"] {
629 std::fs::write(&path, format!("{base}repo = \"{repo}\"\n")).unwrap();
630 assert!(Config::load(&path).is_err(), "repo `{repo}` should be refused");
631 }
632 std::fs::write(&path, format!("{base}repo = \"Tahinli/forge\"\n")).unwrap();
633 let cfg = Config::load(&path).unwrap();
634 assert!(cfg.self_update.enabled);
635 assert_eq!(cfg.self_update.repo, "Tahinli/forge");
636 }
637
638 #[test]
639 fn self_update_rejects_unknown_keys() {
640 let dir = tempfile::tempdir().unwrap();
641 let path = dir.path().join("forge.toml");
642 std::fs::write(&path, format!("{DEV_DEFAULTS}\n[self_update]\nbogus = 1\n")).unwrap();
643 assert!(Config::load(&path).is_err());
644 }
645
646 #[test]
647 fn backup_section_is_parsed_and_validated() {
648 let dir = tempfile::tempdir().unwrap();
649 let path = dir.path().join("forge.toml");
650 let text = format!(
651 "{DEV_DEFAULTS}\n[backup]\ndir = \"/srv/backups\"\nkeep = 3\ninterval_hours = 6\n"
652 );
653 std::fs::write(&path, &text).unwrap();
654 let cfg = Config::load(&path).unwrap();
655 assert!(cfg.backup.enabled());
656 assert_eq!(cfg.backup.dir, PathBuf::from("/srv/backups"));
657 assert_eq!(cfg.backup.keep, 3);
658 assert_eq!(cfg.backup.interval_hours, 6);
659
660 std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nkeep = 0\n")).unwrap();
661 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
662 std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nbogus = 1\n")).unwrap();
663 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
664 }
665}