Skip to main content

kellnr_settings/
registry.rs

1use std::num::NonZeroU64;
2use std::time::Duration;
3
4use provcfg::{ClapArgs, Configurable};
5use serde::{Deserialize, Serialize};
6use utoipa::ToSchema;
7
8use crate::compile_time_config;
9use crate::constants::MAX_SESSION_AGE;
10
11fn default_data_dir() -> String {
12    // Priority: runtime env var > compile-time > empty (must be set via CLI or config)
13    std::env::var("KELLNR_DATA_DIR")
14        .ok()
15        .or_else(|| compile_time_config::KELLNR_COMPTIME__DATA_DIR.map(String::from))
16        .unwrap_or_default()
17}
18
19#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs, ToSchema)]
20#[serde(default)]
21#[configurable(clap_prefix = "registry")]
22#[allow(clippy::struct_excessive_bools)]
23pub struct Registry {
24    /// Data directory for crates, index, and database
25    #[arg(short = 'd')]
26    pub data_dir: String,
27
28    /// Session lifetime in seconds, counted from the login (0 = sessions
29    /// never expire)
30    #[arg(long = "registry-session-age")]
31    pub session_age_seconds: u64,
32
33    /// Max number of crates in the in-memory crate cache (0 = disabled)
34    pub cache_size: u64,
35
36    /// Max total size of the in-memory crate cache in MB (0 = no size limit,
37    /// only `cache_size` applies)
38    pub cache_max_size: u64,
39
40    /// Max crate size in MB
41    pub max_crate_size: u64,
42
43    /// Max database connections (0 = unlimited)
44    pub max_db_connections: u32,
45
46    /// Require authentication for all operations
47    pub auth_required: bool,
48
49    /// Required crate fields (comma-separated)
50    #[configurable(env_list)]
51    #[arg(value_delimiter = ',')]
52    pub required_crate_fields: Vec<String>,
53
54    /// Restrict new crate uploads to admins
55    pub new_crates_restricted: bool,
56
57    /// Cookie signing key (for multi-instance setups). At least 64 bytes of
58    /// random data; generate it with e.g. `openssl rand -hex 64`. Unset, a
59    /// random key is generated on every start
60    pub cookie_signing_key: Option<String>,
61
62    /// Allow crates without owners
63    pub allow_ownerless_crates: bool,
64
65    /// Enable token cache
66    pub token_cache_enabled: bool,
67
68    /// Token cache TTL in seconds
69    #[arg(long = "registry-token-cache-ttl")]
70    pub token_cache_ttl_seconds: u64,
71
72    /// Token cache max capacity
73    pub token_cache_max_capacity: u64,
74
75    /// Token DB retry count
76    pub token_db_retry_count: u32,
77
78    /// Token DB retry delay in ms
79    #[arg(long = "registry-token-db-retry-delay")]
80    pub token_db_retry_delay_ms: u64,
81
82    /// Download request timeout in seconds (0 = disabled)
83    #[arg(long = "registry-download-timeout")]
84    pub download_timeout_seconds: u64,
85
86    /// Max concurrent download requests (0 = unlimited)
87    pub download_max_concurrent: usize,
88
89    /// Download counter flush interval in seconds (0 = flush every download)
90    #[arg(long = "registry-download-counter-flush")]
91    pub download_counter_flush_seconds: u64,
92
93    /// Password attempts allowed per username within the window before
94    /// further attempts are rejected with HTTP 429 (0 = disabled). Applies to
95    /// web UI login and cargo basic auth; a successful login resets the count
96    pub auth_max_failed_attempts: u32,
97
98    /// Window in seconds for counting password attempts per username
99    #[arg(long = "registry-auth-failed-attempts-window")]
100    pub auth_failed_attempts_window_seconds: u64,
101
102    /// Show links to sponsor Kellnr in the web UI and the startup log
103    pub sponsor_links_enabled: bool,
104}
105
106impl Registry {
107    pub fn session_age(&self) -> SessionAge {
108        SessionAge::from_secs(self.session_age_seconds)
109    }
110
111    pub fn token_cache_ttl(&self) -> Duration {
112        Duration::from_secs(self.token_cache_ttl_seconds)
113    }
114
115    pub fn auth_failed_attempts_window(&self) -> Duration {
116        Duration::from_secs(self.auth_failed_attempts_window_seconds)
117    }
118
119    /// `max_crate_size` in bytes.
120    pub fn max_crate_bytes(&self) -> usize {
121        mb_to_bytes(self.max_crate_size)
122    }
123
124    /// `cache_max_size` in bytes, `None` if the size of the crate cache is
125    /// not limited.
126    pub fn cache_max_bytes(&self) -> Option<u64> {
127        (self.cache_max_size > 0).then(|| self.cache_max_size.saturating_mul(1_000_000))
128    }
129}
130
131/// Converts a size in MB from the settings to bytes, saturating instead of
132/// overflowing.
133pub(crate) fn mb_to_bytes(mb: impl TryInto<usize>) -> usize {
134    mb.try_into()
135        .unwrap_or(usize::MAX)
136        .saturating_mul(1_000_000)
137}
138
139/// Lifetime of a session, counted from the login.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
141pub enum SessionAge {
142    /// Sessions never expire, configured as zero seconds.
143    Never,
144    /// Sessions expire after the given number of seconds.
145    For(NonZeroU64),
146}
147
148impl SessionAge {
149    /// Zero seconds means sessions never expire.
150    pub fn from_secs(secs: u64) -> Self {
151        NonZeroU64::new(secs).map_or(Self::Never, Self::For)
152    }
153
154    /// The time after which a session is expired, `None` if sessions never
155    /// expire. At most [`MAX_SESSION_AGE`], so it can be subtracted from a
156    /// timestamp without overflow.
157    pub fn expiry(self) -> Option<Duration> {
158        match self {
159            Self::Never => None,
160            Self::For(secs) => Some(Duration::from_secs(secs.get()).min(MAX_SESSION_AGE)),
161        }
162    }
163
164    /// `Max-Age` of the session cookie. A cookie needs one, so sessions
165    /// that never expire use [`MAX_SESSION_AGE`].
166    pub fn cookie_max_age(self) -> Duration {
167        self.expiry().unwrap_or(MAX_SESSION_AGE)
168    }
169}
170
171impl Default for Registry {
172    fn default() -> Self {
173        Self {
174            data_dir: default_data_dir(),
175            session_age_seconds: 60 * 60 * 8,
176            cache_size: 1000,
177            cache_max_size: 0,
178            max_crate_size: 10,
179            max_db_connections: 0,
180            auth_required: false,
181            required_crate_fields: Vec::new(),
182            new_crates_restricted: false,
183            cookie_signing_key: None,
184            allow_ownerless_crates: false,
185            token_cache_enabled: true,
186            token_cache_ttl_seconds: 1800,
187            token_cache_max_capacity: 10000,
188            token_db_retry_count: 3,
189            token_db_retry_delay_ms: 100,
190            download_timeout_seconds: 60,
191            download_max_concurrent: 20,
192            download_counter_flush_seconds: 30,
193            auth_max_failed_attempts: 10,
194            auth_failed_attempts_window_seconds: 300,
195            sponsor_links_enabled: true,
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn registry(session_age_seconds: u64) -> Registry {
205        Registry {
206            session_age_seconds,
207            ..Registry::default()
208        }
209    }
210
211    #[test]
212    fn session_age_of_zero_never_expires() {
213        let age = registry(0).session_age();
214        assert_eq!(age, SessionAge::Never);
215        assert_eq!(age.expiry(), None);
216        assert_eq!(age.cookie_max_age(), MAX_SESSION_AGE);
217    }
218
219    #[test]
220    fn session_age_is_the_configured_age() {
221        let age = registry(60).session_age();
222        assert_eq!(age, SessionAge::For(NonZeroU64::new(60).unwrap()));
223        assert_eq!(age.expiry(), Some(Duration::from_mins(1)));
224        assert_eq!(age.cookie_max_age(), Duration::from_mins(1));
225    }
226
227    #[test]
228    fn session_age_is_bounded() {
229        let age = registry(u64::MAX).session_age();
230        assert_eq!(age.expiry(), Some(MAX_SESSION_AGE));
231        assert_eq!(age.cookie_max_age(), MAX_SESSION_AGE);
232    }
233
234    #[test]
235    fn durations_are_read_in_seconds() {
236        let registry = Registry {
237            token_cache_ttl_seconds: 90,
238            auth_failed_attempts_window_seconds: 300,
239            ..Registry::default()
240        };
241        assert_eq!(registry.token_cache_ttl(), Duration::from_secs(90));
242        assert_eq!(
243            registry.auth_failed_attempts_window(),
244            Duration::from_mins(5)
245        );
246    }
247
248    #[test]
249    fn sizes_are_read_in_mb_and_saturate() {
250        let mut registry = Registry {
251            max_crate_size: 10,
252            cache_max_size: 0,
253            ..Registry::default()
254        };
255        assert_eq!(registry.max_crate_bytes(), 10_000_000);
256        assert_eq!(registry.cache_max_bytes(), None);
257
258        registry.max_crate_size = u64::MAX;
259        registry.cache_max_size = u64::MAX;
260        assert_eq!(registry.max_crate_bytes(), usize::MAX);
261        assert_eq!(registry.cache_max_bytes(), Some(u64::MAX));
262
263        registry.cache_max_size = 2;
264        assert_eq!(registry.cache_max_bytes(), Some(2_000_000));
265    }
266}