Skip to main content

khive_db/
disk_guard_config.rs

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/// Provenance is diagnostic; daemon compatibility uses only effective numbers.
14#[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/// One construction-time snapshot shared by backend opening and config identity.
67/// Raw values remain intact so invalid Unicode and overflow fail at validation.
68#[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    /// The legacy setting remains a fallback. Equal old/new values are accepted;
85    /// conflicting values must be removed even when a backend overrides them.
86    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
132/// The legacy reserve keeps the grammar it had before this resolver, which
133/// accepted one leading `+`; existing installations must not start failing.
134fn 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
161/// The directory that holds the volume advisory lock files shared by every
162/// khive process of one user.
163///
164/// `KHIVE_VOLUME_LOCK_DIR` wins when it is set and not blank. Otherwise the
165/// per-user runtime namespace `<home>/.khive/sqlite-volume-locks` is used, where
166/// `<home>` is the first non-blank of `HOME` and `USERPROFILE`. Every process of
167/// that user therefore resolves the same directory, whatever its working
168/// directory. With none of these set, or when the resolved directory is not
169/// absolute, the result is a configuration error rather than a relative path,
170/// because a working-directory-relative lock directory would give two processes
171/// two different lock files.
172///
173/// A process carrying the workspace test marker `KHIVE_TEST_HARNESS=1` (cargo
174/// sets it for every test and test-spawned binary) uses
175/// `<temp>/khive-test-sqlite-volume-locks/<pid>` in place of the per-user
176/// namespace, so a test process never waits on a lease held by an installed
177/// process of the same user or by another test process. Tests whose processes
178/// must contend name one directory explicitly. The explicit override still
179/// wins under the marker.
180pub 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
196/// A caller's optional lock directory, or the configuration error that
197/// [`default_volume_lock_dir`] reports when no directory can be resolved.
198pub fn require_volume_lock_dir(configured: Option<PathBuf>) -> Result<PathBuf, SqliteError> {
199    configured.ok_or_else(unresolved_volume_lock_dir)
200}
201
202/// Env-free core of [`default_volume_lock_dir`], so the order is testable
203/// without mutating process-global environment variables.
204fn 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}