Skip to main content

sui_cache/
config.rs

1//! Cache configuration types.
2//!
3//! [`BackendConfig`] (the storage backend selector) lives in `sui-castore` and
4//! is re-exported here for backward compatibility. [`CacheConfig`] (the
5//! cache-server configuration) is owned by this module.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11// BackendConfig is defined in sui-castore; import it for use in CacheConfig.
12pub use sui_castore::BackendConfig;
13pub use sui_castore::WritePolicy;
14
15/// Top-level cache configuration.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct CacheConfig {
18    /// Network address to listen on.
19    pub listen: String,
20    /// Storage backend configuration.
21    pub backend: BackendConfig,
22    /// Path to the ed25519 signing secret key file.
23    ///
24    /// In production this path is a cofre/ESO-materialized Kubernetes Secret
25    /// mount, never a plaintext literal. When set, the daemon signs every
26    /// ingested narinfo (see [`serve`](crate::server::serve)).
27    pub signing_key: Option<PathBuf>,
28    /// Cache priority (lower = preferred). Reported in nix-cache-info.
29    pub priority: u32,
30    /// Whether to want mass query (narinfo pipelining).
31    pub want_mass_query: bool,
32    /// The Nix store directory (almost always `/nix/store`).
33    pub store_dir: String,
34    /// Whether this cache's consumers should require a valid signature.
35    ///
36    /// This is a serving-side advertisement of the fail-closed posture: a
37    /// signing cache SHOULD publish `require_sigs = true` so operators know
38    /// the served paths are signed and consumers must verify. It does not by
39    /// itself change what the daemon serves (signing is driven by
40    /// `signing_key`); it is the typed knob a consuming config reads to know
41    /// the cache is trustworthy fail-closed. Defaults to `false` to preserve
42    /// legacy behavior for caches that have not yet been given a key.
43    #[serde(default)]
44    pub require_sigs: bool,
45}
46
47impl Default for CacheConfig {
48    fn default() -> Self {
49        Self {
50            listen: "0.0.0.0:5000".to_string(),
51            backend: BackendConfig::default(),
52            signing_key: None,
53            priority: 40,
54            want_mass_query: true,
55            store_dir: "/nix/store".to_string(),
56            require_sigs: false,
57        }
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use std::path::PathBuf;
65
66    #[test]
67    fn default_config_has_sane_values() {
68        let config = CacheConfig::default();
69        assert_eq!(config.listen, "0.0.0.0:5000");
70        assert_eq!(config.store_dir, "/nix/store");
71        assert_eq!(config.priority, 40);
72        assert!(config.want_mass_query);
73        assert!(config.signing_key.is_none());
74    }
75
76    #[test]
77    fn default_backend_is_local() {
78        let config = CacheConfig::default();
79        assert!(matches!(config.backend, BackendConfig::Local { .. }));
80    }
81
82    #[test]
83    fn config_serializes_to_json() {
84        let config = CacheConfig::default();
85        let json = serde_json::to_string(&config).unwrap();
86        assert!(json.contains("local"));
87        assert!(json.contains("5000"));
88    }
89
90    #[test]
91    fn config_roundtrips_through_json() {
92        let config = CacheConfig {
93            listen: "127.0.0.1:8080".to_string(),
94            backend: BackendConfig::S3 {
95                bucket: "my-cache".to_string(),
96                region: "us-east-1".to_string(),
97                endpoint: Some("http://localhost:9000".to_string()),
98            },
99            signing_key: Some(PathBuf::from("/tmp/key.sec")),
100            priority: 30,
101            want_mass_query: false,
102            store_dir: "/nix/store".to_string(),
103            require_sigs: true,
104        };
105        let json = serde_json::to_string_pretty(&config).unwrap();
106        let parsed: CacheConfig = serde_json::from_str(&json).unwrap();
107        assert_eq!(parsed.listen, "127.0.0.1:8080");
108        assert_eq!(parsed.priority, 30);
109        assert!(!parsed.want_mass_query);
110        assert!(parsed.require_sigs);
111        assert!(matches!(parsed.backend, BackendConfig::S3 { .. }));
112    }
113
114    #[test]
115    fn require_sigs_defaults_to_false_when_absent() {
116        // A config JSON that omits require_sigs deserializes to false
117        // (the serde default) — legacy configs keep working.
118        let json = r#"{
119            "listen": "0.0.0.0:5000",
120            "backend": { "type": "local", "path": "/var/cache/sui" },
121            "signing_key": null,
122            "priority": 40,
123            "want_mass_query": true,
124            "store_dir": "/nix/store"
125        }"#;
126        let parsed: CacheConfig = serde_json::from_str(json).unwrap();
127        assert!(!parsed.require_sigs);
128    }
129
130    #[test]
131    fn tiered_backend_roundtrips_through_json() {
132        let backend = BackendConfig::Tiered {
133            l1: Box::new(BackendConfig::Redis {
134                url: "redis://redis:6379".to_string(),
135                ttl_secs: Some(3600),
136            }),
137            l2: Box::new(BackendConfig::Pg {
138                url: "postgres://pg:5432/sui".to_string(),
139                max_conns: 16,
140            }),
141            l3: Box::new(BackendConfig::S3 {
142                bucket: "sui-super-cache".to_string(),
143                region: "us-east-1".to_string(),
144                endpoint: None,
145            }),
146            write_policy: WritePolicy::WriteThrough,
147        };
148        let json = serde_json::to_string_pretty(&backend).unwrap();
149        assert!(json.contains("tiered"));
150        assert!(json.contains("redis"));
151        assert!(json.contains("write-through"));
152        let parsed: BackendConfig = serde_json::from_str(&json).unwrap();
153        match parsed {
154            BackendConfig::Tiered { l1, l2, l3, write_policy } => {
155                assert!(matches!(*l1, BackendConfig::Redis { .. }));
156                assert!(matches!(*l2, BackendConfig::Pg { .. }));
157                assert!(matches!(*l3, BackendConfig::S3 { .. }));
158                assert_eq!(write_policy, WritePolicy::WriteThrough);
159            }
160            other => panic!("expected tiered, got {other:?}"),
161        }
162    }
163
164    #[test]
165    fn tiered_write_policy_defaults_when_absent() {
166        // A tiered config that omits `write_policy` deserializes to the default.
167        let json = r#"{
168            "type": "tiered",
169            "l1": { "type": "redis", "url": "redis://r:6379" },
170            "l2": { "type": "pg", "url": "postgres://p:5432/s", "max_conns": 8 },
171            "l3": { "type": "local", "path": "/var/cache/sui" }
172        }"#;
173        let parsed: BackendConfig = serde_json::from_str(json).unwrap();
174        match parsed {
175            BackendConfig::Tiered { write_policy, .. } => {
176                assert_eq!(write_policy, WritePolicy::default());
177                assert_eq!(write_policy, WritePolicy::WriteThrough);
178            }
179            other => panic!("expected tiered, got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn redis_ttl_defaults_to_none() {
185        let json = r#"{ "type": "redis", "url": "redis://r:6379" }"#;
186        let parsed: BackendConfig = serde_json::from_str(json).unwrap();
187        assert!(matches!(parsed, BackendConfig::Redis { ttl_secs: None, .. }));
188    }
189}