1use std::ffi::{OsStr, OsString};
2use std::path::PathBuf;
3
4use crate::SqliteError;
5
6const RESERVE_ENV: &str = "KHIVE_SQLITE_DISK_RESERVE_BYTES";
7const LEGACY_RESERVE_ENV: &str = "KHIVE_DB_FREE_SPACE_FLOOR_BYTES";
8const DEADLINE_ENV: &str = "KHIVE_SQLITE_DISK_GUARD_DEADLINE_MS";
9const VOLUME_LOCK_DIR_ENV: &str = "KHIVE_VOLUME_LOCK_DIR";
10pub(crate) const DEFAULT_DISK_RESERVE_BYTES: u64 = 1_073_741_824;
11pub(crate) const DEFAULT_DISK_GUARD_DEADLINE_MS: u64 = 2_000;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum DiskGuardConfigSource {
16 Backend,
17 Environment,
18 LegacyEnvironment,
19 Default,
20}
21
22impl DiskGuardConfigSource {
23 pub const fn as_str(self) -> &'static str {
24 match self {
25 Self::Backend => "backend",
26 Self::Environment => "environment",
27 Self::LegacyEnvironment => "legacy_environment",
28 Self::Default => "default",
29 }
30 }
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct EffectiveDiskGuardConfig {
35 pub reserve_bytes: u64,
36 pub guard_deadline_ms: u64,
37 pub reserve_source: DiskGuardConfigSource,
38 pub deadline_source: DiskGuardConfigSource,
39 pub legacy_environment_present: bool,
40}
41
42impl Default for EffectiveDiskGuardConfig {
43 fn default() -> Self {
44 Self {
45 reserve_bytes: DEFAULT_DISK_RESERVE_BYTES,
46 guard_deadline_ms: DEFAULT_DISK_GUARD_DEADLINE_MS,
47 reserve_source: DiskGuardConfigSource::Default,
48 deadline_source: DiskGuardConfigSource::Default,
49 legacy_environment_present: false,
50 }
51 }
52}
53
54impl EffectiveDiskGuardConfig {
55 pub fn validate(&self) -> Result<(), SqliteError> {
56 if !(100..=10_000).contains(&self.guard_deadline_ms) {
57 return Err(SqliteError::InvalidConfig(format!(
58 "disk_guard_deadline_ms must be in [100, 10000] ms, got {}",
59 self.guard_deadline_ms
60 )));
61 }
62 Ok(())
63 }
64}
65
66#[derive(Clone, Debug, Default)]
69pub struct DiskGuardEnvironment {
70 pub reserve: Option<OsString>,
71 pub legacy_reserve: Option<OsString>,
72 pub deadline: Option<OsString>,
73}
74
75impl DiskGuardEnvironment {
76 pub fn capture() -> Self {
77 Self {
78 reserve: std::env::var_os(RESERVE_ENV),
79 legacy_reserve: std::env::var_os(LEGACY_RESERVE_ENV),
80 deadline: std::env::var_os(DEADLINE_ENV),
81 }
82 }
83
84 pub fn resolve(
87 &self,
88 reserve_override: Option<u64>,
89 deadline_override: Option<u64>,
90 ) -> Result<EffectiveDiskGuardConfig, SqliteError> {
91 let reserve = parse_optional(RESERVE_ENV, self.reserve.as_deref(), false)?;
92 let legacy = parse_optional(LEGACY_RESERVE_ENV, self.legacy_reserve.as_deref(), true)?;
93 let deadline = parse_optional(DEADLINE_ENV, self.deadline.as_deref(), false)?;
94 if let (Some(new), Some(old)) = (reserve, legacy) {
95 if new != old {
96 return Err(SqliteError::InvalidConfig(format!(
97 "{RESERVE_ENV} and {LEGACY_RESERVE_ENV} conflict"
98 )));
99 }
100 }
101 let (reserve_bytes, reserve_source) = if let Some(value) = reserve_override {
102 (value, DiskGuardConfigSource::Backend)
103 } else if let Some(value) = reserve {
104 (value, DiskGuardConfigSource::Environment)
105 } else if let Some(value) = legacy {
106 (value, DiskGuardConfigSource::LegacyEnvironment)
107 } else {
108 (DEFAULT_DISK_RESERVE_BYTES, DiskGuardConfigSource::Default)
109 };
110 let (guard_deadline_ms, deadline_source) = if let Some(value) = deadline_override {
111 (value, DiskGuardConfigSource::Backend)
112 } else if let Some(value) = deadline {
113 (value, DiskGuardConfigSource::Environment)
114 } else {
115 (
116 DEFAULT_DISK_GUARD_DEADLINE_MS,
117 DiskGuardConfigSource::Default,
118 )
119 };
120 let policy = EffectiveDiskGuardConfig {
121 reserve_bytes,
122 guard_deadline_ms,
123 reserve_source,
124 deadline_source,
125 legacy_environment_present: legacy.is_some(),
126 };
127 policy.validate()?;
128 Ok(policy)
129 }
130}
131
132fn parse_optional(
135 name: &str,
136 raw: Option<&OsStr>,
137 legacy: bool,
138) -> Result<Option<u64>, SqliteError> {
139 raw.map(|raw| {
140 raw.to_str()
141 .map(|value| match value.strip_prefix('+') {
142 Some(unsigned) if legacy => unsigned,
143 _ => value,
144 })
145 .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
146 .and_then(|value| value.parse().ok())
147 .ok_or_else(|| {
148 SqliteError::InvalidConfig(format!("{name} must be an unsigned decimal integer"))
149 })
150 })
151 .transpose()
152}
153
154pub fn resolve_disk_guard_config(
155 reserve_override: Option<u64>,
156 deadline_override: Option<u64>,
157) -> Result<EffectiveDiskGuardConfig, SqliteError> {
158 DiskGuardEnvironment::capture().resolve(reserve_override, deadline_override)
159}
160
161pub fn default_volume_lock_dir() -> Result<PathBuf, SqliteError> {
181 let test_harness = std::env::var(crate::pool::TEST_HARNESS_ENV).as_deref() == Ok("1");
182 volume_lock_dir_from(
183 std::env::var_os(VOLUME_LOCK_DIR_ENV),
184 test_harness.then(|| {
185 std::env::temp_dir()
186 .join(TEST_HARNESS_LOCK_SUBDIR)
187 .join(std::process::id().to_string())
188 }),
189 std::env::var_os("HOME"),
190 std::env::var_os("USERPROFILE"),
191 )
192}
193
194const TEST_HARNESS_LOCK_SUBDIR: &str = "khive-test-sqlite-volume-locks";
195
196pub fn require_volume_lock_dir(configured: Option<PathBuf>) -> Result<PathBuf, SqliteError> {
199 configured.ok_or_else(unresolved_volume_lock_dir)
200}
201
202fn volume_lock_dir_from(
205 override_dir: Option<OsString>,
206 test_harness_dir: Option<PathBuf>,
207 home: Option<OsString>,
208 userprofile: Option<OsString>,
209) -> Result<PathBuf, SqliteError> {
210 let is_set = |value: &OsString| !value.to_str().is_some_and(|text| text.trim().is_empty());
211 let directory = match (override_dir.filter(is_set), test_harness_dir) {
212 (Some(directory), _) => PathBuf::from(directory),
213 (None, Some(directory)) => directory,
214 (None, None) => {
215 let home = home
216 .filter(is_set)
217 .or_else(|| userprofile.filter(is_set))
218 .ok_or_else(unresolved_volume_lock_dir)?;
219 PathBuf::from(home)
220 .join(".khive")
221 .join("sqlite-volume-locks")
222 }
223 };
224 if !directory.is_absolute() {
225 return Err(SqliteError::InvalidConfig(format!(
226 "SQLite volume-lock directory {directory:?} is not absolute: {VOLUME_LOCK_DIR_ENV} \
227 must be absolute, and so must HOME or USERPROFILE when it is unset"
228 )));
229 }
230 Ok(directory)
231}
232
233fn unresolved_volume_lock_dir() -> SqliteError {
234 SqliteError::InvalidConfig(format!(
235 "no SQLite volume-lock directory: {VOLUME_LOCK_DIR_ENV} is unset and neither HOME nor \
236 USERPROFILE names a home directory; set {VOLUME_LOCK_DIR_ENV} to an absolute directory \
237 shared by every khive process of this user"
238 ))
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn effective_numbers_follow_backend_environment_legacy_default_precedence() {
247 let empty = DiskGuardEnvironment::default();
248 assert_eq!(
249 empty.resolve(None, None).unwrap(),
250 EffectiveDiskGuardConfig::default()
251 );
252 let legacy = DiskGuardEnvironment {
253 legacy_reserve: Some("123".into()),
254 deadline: Some("2500".into()),
255 ..Default::default()
256 };
257 let policy = legacy.resolve(None, None).unwrap();
258 assert_eq!(
259 (policy.reserve_bytes, policy.guard_deadline_ms),
260 (123, 2500)
261 );
262 assert_eq!(
263 policy.reserve_source,
264 DiskGuardConfigSource::LegacyEnvironment
265 );
266 assert!(policy.legacy_environment_present);
267 let current = DiskGuardEnvironment {
268 reserve: Some("123".into()),
269 ..legacy
270 };
271 assert_eq!(
272 current.resolve(None, None).unwrap().reserve_source,
273 DiskGuardConfigSource::Environment
274 );
275 let overridden = current.resolve(Some(0), Some(100)).unwrap();
276 assert_eq!(
277 (overridden.reserve_bytes, overridden.guard_deadline_ms),
278 (0, 100)
279 );
280 assert_eq!(overridden.reserve_source, DiskGuardConfigSource::Backend);
281 assert_eq!(overridden.deadline_source, DiskGuardConfigSource::Backend);
282 assert!(empty.resolve(Some(u64::MAX), Some(10_000)).is_ok());
283 }
284
285 #[test]
286 fn conflicting_legacy_environment_is_never_silently_ignored() {
287 let environment = DiskGuardEnvironment {
288 reserve: Some("100".into()),
289 legacy_reserve: Some("200".into()),
290 ..Default::default()
291 };
292 for override_value in [None, Some(300)] {
293 assert!(matches!(environment.resolve(override_value, None),
294 Err(SqliteError::InvalidConfig(message)) if message.contains("conflict")));
295 }
296 }
297
298 #[test]
299 fn malformed_environment_and_unbounded_deadlines_are_rejected() {
300 for raw in ["", "-1", "++1", "+", "1.5", "1 ", "18446744073709551616"] {
301 for environment in [
302 DiskGuardEnvironment {
303 reserve: Some(raw.into()),
304 ..Default::default()
305 },
306 DiskGuardEnvironment {
307 legacy_reserve: Some(raw.into()),
308 ..Default::default()
309 },
310 DiskGuardEnvironment {
311 deadline: Some(raw.into()),
312 ..Default::default()
313 },
314 ] {
315 assert!(matches!(
316 environment.resolve(Some(0), Some(2000)),
317 Err(SqliteError::InvalidConfig(_))
318 ));
319 }
320 }
321 for deadline in [0, 99, 10_001, u64::MAX] {
322 assert!(EffectiveDiskGuardConfig {
323 guard_deadline_ms: deadline,
324 ..Default::default()
325 }
326 .validate()
327 .is_err());
328 assert!(DiskGuardEnvironment::default()
329 .resolve(None, Some(deadline))
330 .is_err());
331 assert!(DiskGuardEnvironment {
332 deadline: Some(deadline.to_string().into()),
333 ..Default::default()
334 }
335 .resolve(None, None)
336 .is_err());
337 }
338 }
339
340 #[test]
341 fn only_the_legacy_reserve_accepts_a_leading_plus() {
342 let legacy = DiskGuardEnvironment {
343 legacy_reserve: Some("+1073741824".into()),
344 ..Default::default()
345 }
346 .resolve(None, None)
347 .unwrap();
348 assert_eq!(legacy.reserve_bytes, 1_073_741_824);
349 assert_eq!(
350 legacy.reserve_source,
351 DiskGuardConfigSource::LegacyEnvironment
352 );
353
354 for environment in [
355 DiskGuardEnvironment {
356 reserve: Some("+1073741824".into()),
357 ..Default::default()
358 },
359 DiskGuardEnvironment {
360 deadline: Some("+2000".into()),
361 ..Default::default()
362 },
363 ] {
364 assert!(matches!(
365 environment.resolve(None, None),
366 Err(SqliteError::InvalidConfig(_))
367 ));
368 }
369 }
370
371 #[cfg(unix)]
372 #[test]
373 fn non_unicode_environment_is_a_configuration_error() {
374 use std::os::unix::ffi::OsStringExt;
375 let environment = DiskGuardEnvironment {
376 reserve: Some(OsString::from_vec(vec![0xff])),
377 ..Default::default()
378 };
379 assert!(matches!(
380 environment.resolve(None, None),
381 Err(SqliteError::InvalidConfig(_))
382 ));
383 }
384
385 fn lock_dir(
386 override_dir: Option<&str>,
387 home: Option<&str>,
388 userprofile: Option<&str>,
389 ) -> Result<PathBuf, SqliteError> {
390 volume_lock_dir_from(
391 override_dir.map(OsString::from),
392 None,
393 home.map(OsString::from),
394 userprofile.map(OsString::from),
395 )
396 }
397
398 fn per_user_lock_dir(home: &str) -> PathBuf {
399 PathBuf::from(home)
400 .join(".khive")
401 .join("sqlite-volume-locks")
402 }
403
404 #[test]
405 fn volume_lock_dir_follows_override_then_home_then_userprofile() {
406 assert_eq!(
407 lock_dir(Some("/locks"), Some("/home/a"), Some("/profile/a")).unwrap(),
408 PathBuf::from("/locks")
409 );
410 assert_eq!(
411 lock_dir(None, Some("/home/a"), Some("/profile/a")).unwrap(),
412 per_user_lock_dir("/home/a")
413 );
414 assert_eq!(
415 lock_dir(None, None, Some("/profile/a")).unwrap(),
416 per_user_lock_dir("/profile/a")
417 );
418 }
419
420 #[test]
421 fn volume_lock_dir_skips_empty_and_blank_values() {
422 assert_eq!(
423 lock_dir(Some(""), Some("/home/a"), None).unwrap(),
424 per_user_lock_dir("/home/a")
425 );
426 assert_eq!(
427 lock_dir(None, Some(""), Some("/profile/a")).unwrap(),
428 per_user_lock_dir("/profile/a")
429 );
430 assert_eq!(
431 lock_dir(None, Some(" "), Some("/profile/a")).unwrap(),
432 per_user_lock_dir("/profile/a")
433 );
434 for blank in [" ", "\t", " \n "] {
435 assert_eq!(
436 lock_dir(Some(blank), Some("/home/a"), Some("/profile/a")).unwrap(),
437 per_user_lock_dir("/home/a")
438 );
439 }
440 assert_eq!(
441 lock_dir(Some(" "), None, Some("/profile/a")).unwrap(),
442 per_user_lock_dir("/profile/a")
443 );
444 }
445
446 #[test]
447 fn volume_lock_dir_refuses_a_relative_directory() {
448 for (override_dir, home, userprofile) in [
449 (Some("locks"), Some("/home/a"), None),
450 (Some("./locks"), None, Some("/profile/a")),
451 (None, Some("home/a"), Some("/profile/a")),
452 (None, None, Some("profile/a")),
453 (None, Some("."), None),
454 ] {
455 match lock_dir(override_dir, home, userprofile) {
456 Err(SqliteError::InvalidConfig(message)) => {
457 assert!(message.contains("KHIVE_VOLUME_LOCK_DIR"), "{message}");
458 assert!(message.contains("must be absolute"), "{message}");
459 }
460 other => panic!(
461 "expected a configuration error for {override_dir:?} {home:?} \
462 {userprofile:?}, got {other:?}"
463 ),
464 }
465 }
466 }
467
468 #[test]
469 fn volume_lock_dir_without_a_home_is_an_error_naming_the_override_variable() {
470 for (override_dir, home, userprofile) in [
471 (None, None, None),
472 (Some(""), Some(""), Some("")),
473 (None, Some(" "), Some("\t")),
474 ] {
475 match lock_dir(override_dir, home, userprofile) {
476 Err(SqliteError::InvalidConfig(message)) => {
477 assert!(message.contains("KHIVE_VOLUME_LOCK_DIR"), "{message}");
478 }
479 other => panic!("expected a configuration error, got {other:?}"),
480 }
481 }
482 }
483
484 #[test]
485 fn volume_lock_dir_under_the_test_marker_is_the_harness_namespace() {
486 let harness = PathBuf::from("/tmp/khive-test-sqlite-volume-locks");
487 let resolve = |override_dir: Option<&str>, harness: Option<PathBuf>| {
488 volume_lock_dir_from(
489 override_dir.map(OsString::from),
490 harness,
491 Some(OsString::from("/home/a")),
492 None,
493 )
494 .unwrap()
495 };
496 assert_eq!(resolve(None, Some(harness.clone())), harness);
497 assert_eq!(resolve(None, None), per_user_lock_dir("/home/a"));
498 assert_eq!(
499 resolve(Some("/locks"), Some(harness)),
500 PathBuf::from("/locks"),
501 "the explicit override still wins under the marker"
502 );
503 }
504
505 #[test]
506 fn default_volume_lock_dir_with_the_test_marker_is_this_process_temp_namespace() {
507 if crate::test_process::run_in_child(|command| {
508 command
509 .env_remove(VOLUME_LOCK_DIR_ENV)
510 .env(crate::pool::TEST_HARNESS_ENV, "1")
511 .env("HOME", "/home/marker-present");
512 }) {
513 return;
514 }
515 assert_eq!(
516 default_volume_lock_dir().unwrap(),
517 std::env::temp_dir()
518 .join(TEST_HARNESS_LOCK_SUBDIR)
519 .join(std::process::id().to_string()),
520 "each test process takes its own namespace"
521 );
522 }
523
524 #[test]
525 fn default_volume_lock_dir_without_the_test_marker_is_the_per_user_namespace() {
526 if crate::test_process::run_in_child(|command| {
527 command
528 .env_remove(VOLUME_LOCK_DIR_ENV)
529 .env_remove(crate::pool::TEST_HARNESS_ENV)
530 .env("HOME", "/home/marker-absent");
531 }) {
532 return;
533 }
534 assert_eq!(
535 default_volume_lock_dir().unwrap(),
536 per_user_lock_dir("/home/marker-absent")
537 );
538 }
539
540 #[test]
541 fn required_volume_lock_dir_names_the_override_variable_when_none_resolved() {
542 assert_eq!(
543 require_volume_lock_dir(Some(PathBuf::from("/locks"))).unwrap(),
544 PathBuf::from("/locks")
545 );
546 assert!(matches!(
547 require_volume_lock_dir(None),
548 Err(SqliteError::InvalidConfig(message)) if message.contains("KHIVE_VOLUME_LOCK_DIR")
549 ));
550 }
551}