Skip to main content

sley_core/
fsync.rs

1//! Repository durability policy: `core.fsync` component selection and
2//! `core.fsyncMethod` barrier choice.
3//!
4//! Promoted from `sley-refs` so every writer resolves one shared policy
5//! instead of hand-rolling barrier decisions. Mirrors upstream git 2.55:
6//!
7//! - component bit layout and aggregate groups (`write-or-die.h`),
8//! - the `core.fsync` grammar with negation accumulation order and
9//!   prefix matching (`environment.c` `parse_fsync_components`),
10//! - method selection including the platform default (`FSYNC_METHOD_DEFAULT`;
11//!   sley keeps the Windows `batch` mapping used by its reference store), and
12//! - the `GIT_TEST_FSYNC` kill switch (`write-or-die.c` `maybe_fsync`,
13//!   default enabled).
14//!
15//! Token handling follows the established sley grammar (`sley-refs`
16//! `core_fsync_includes_reference`): comma-separated components are trimmed,
17//! so unlike upstream's raw `strspn`/`strncmp` scan a trailing space inside a
18//! token still matches. Unknown components are ignored.
19
20use std::env;
21use std::fs;
22use std::io;
23use std::path::Path;
24
25/// The set of repository parts to harden through an [`FsyncMethod`] barrier,
26/// mirroring upstream `enum fsync_component` (git 2.55 `write-or-die.h`).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct FsyncComponents(u32);
29
30impl FsyncComponents {
31    /// Upstream `FSYNC_COMPONENT_NONE`: data that is not persistent and must
32    /// never be synced.
33    pub const NONE: Self = Self(0);
34    pub const LOOSE_OBJECT: Self = Self(1 << 0);
35    pub const PACK: Self = Self(1 << 1);
36    pub const PACK_METADATA: Self = Self(1 << 2);
37    pub const COMMIT_GRAPH: Self = Self(1 << 3);
38    pub const INDEX: Self = Self(1 << 4);
39    pub const REFERENCE: Self = Self(1 << 5);
40    pub const OBJECT_MAP: Self = Self(1 << 6);
41
42    /// Upstream `FSYNC_COMPONENTS_OBJECTS`.
43    pub const OBJECTS: Self = Self(Self::LOOSE_OBJECT.0 | Self::PACK.0);
44
45    /// Upstream `FSYNC_COMPONENTS_DERIVED_METADATA`.
46    pub const DERIVED_METADATA: Self = Self(Self::PACK_METADATA.0 | Self::COMMIT_GRAPH.0);
47
48    /// Upstream `FSYNC_COMPONENTS_DEFAULT`: everything except loose objects.
49    pub const DEFAULT: Self = Self(
50        (Self::OBJECTS.0 | Self::DERIVED_METADATA.0) & !Self::LOOSE_OBJECT.0,
51    );
52
53    /// Upstream `FSYNC_COMPONENTS_COMMITTED`.
54    pub const COMMITTED: Self = Self(Self::OBJECTS.0 | Self::REFERENCE.0);
55
56    /// Upstream `FSYNC_COMPONENTS_ADDED`.
57    pub const ADDED: Self = Self(Self::COMMITTED.0 | Self::INDEX.0);
58
59    /// Upstream `FSYNC_COMPONENTS_ALL`.
60    pub const ALL: Self = Self(
61        Self::LOOSE_OBJECT.0
62            | Self::PACK.0
63            | Self::PACK_METADATA.0
64            | Self::COMMIT_GRAPH.0
65            | Self::INDEX.0
66            | Self::REFERENCE.0
67            | Self::OBJECT_MAP.0,
68    );
69
70    /// Upstream `FSYNC_COMPONENTS_PLATFORM_DEFAULT`. No platform overrides it
71    /// in git v2.55, so this equals [`Self::DEFAULT`] everywhere today; kept
72    /// separate because git exposes the default as compile-time platform
73    /// policy and `parse` starts from it.
74    pub const PLATFORM_DEFAULT: Self = Self::DEFAULT;
75
76    /// `(name, bits)` rows in upstream's `fsync_component_names` order. Group
77    /// names expand here rather than at parse time, matching upstream.
78    const COMPONENT_TABLE: [(&str, Self); 11] = [
79        ("loose-object", Self::LOOSE_OBJECT),
80        ("pack", Self::PACK),
81        ("pack-metadata", Self::PACK_METADATA),
82        ("commit-graph", Self::COMMIT_GRAPH),
83        ("index", Self::INDEX),
84        ("objects", Self::OBJECTS),
85        ("reference", Self::REFERENCE),
86        ("derived-metadata", Self::DERIVED_METADATA),
87        ("committed", Self::COMMITTED),
88        ("added", Self::ADDED),
89        ("all", Self::ALL),
90    ];
91
92    /// Parse one `core.fsync` value into a component set.
93    ///
94    /// Grammar (upstream `parse_fsync_components`): start from
95    /// [`Self::PLATFORM_DEFAULT`]; `none` resets the running base to empty;
96    /// each remaining comma-separated component is trimmed and prefix-matched
97    /// against `Self::COMPONENT_TABLE`, accumulating into negative or
98    /// positive masks by leading `-`; finally the result is
99    /// `(base & ~negative) | positive`, so a component named both ways wins
100    /// as positive in either order. Unknown components are ignored, and a
101    /// bare `-` ends parsing like upstream's warning path.
102    pub fn parse(value: &str) -> Self {
103        let mut current = Self::PLATFORM_DEFAULT;
104        let mut positive = Self::NONE;
105        let mut negative = Self::NONE;
106        for raw_component in value.split(',') {
107            let component = raw_component.trim();
108            if component == "none" {
109                current = Self::NONE;
110                continue;
111            }
112            if component.is_empty() {
113                continue;
114            }
115            let Some(name) = component.strip_prefix('-') else {
116                for (table_name, bits) in Self::COMPONENT_TABLE {
117                    if table_name.starts_with(component) {
118                        positive = positive.union(bits);
119                    }
120                }
121                continue;
122            };
123            if name.is_empty() {
124                break;
125            }
126            for (table_name, bits) in Self::COMPONENT_TABLE {
127                if table_name.starts_with(name) {
128                    negative = negative.union(bits);
129                }
130            }
131        }
132        current.without(negative).union(positive)
133    }
134
135    /// Whether every bit of `other` is present.
136    pub const fn contains(self, other: Self) -> bool {
137        self.0 & other.0 == other.0
138    }
139
140    /// Union with `other`.
141    pub const fn union(self, other: Self) -> Self {
142        Self(self.0 | other.0)
143    }
144
145    /// Remove all bits of `other`.
146    pub const fn without(self, other: Self) -> Self {
147        Self(self.0 & !other.0)
148    }
149
150    /// Raw bitmask, mainly for tests and diagnostics.
151    pub const fn bits(self) -> u32 {
152        self.0
153    }
154
155    /// Whether reference files are included, replacing the old
156    /// `core_fsync_includes_reference` predicate on equal terms.
157    pub const fn includes_reference(self) -> bool {
158        self.contains(Self::REFERENCE)
159    }
160}
161
162/// Barrier implementation backing `core.fsyncMethod`, mirroring upstream
163/// `enum fsync_method`.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum FsyncMethod {
166    /// `fsync`: full data + metadata flush (`fsync(2)` / `FlushFileBuffers`).
167    Fsync,
168    /// `writeout-only`: page-cache writeback without a hardware cache flush.
169    WriteoutOnly,
170    /// `batch`: writeout-only staging with one hardware flush per operation
171    /// (treated identically to [`FsyncMethod::Fsync`] outside bulk checkin).
172    Batch,
173}
174
175impl FsyncMethod {
176    /// Upstream `FSYNC_METHOD_DEFAULT`: `writeout-only` on Apple platforms,
177    /// `batch` on Windows (upstream Min builds flush via `FlushFileBuffers`),
178    /// full `fsync` elsewhere.
179    pub const fn platform_default() -> Self {
180        #[cfg(target_os = "windows")]
181        {
182            Self::Batch
183        }
184        #[cfg(all(not(target_os = "windows"), target_os = "macos"))]
185        {
186            Self::WriteoutOnly
187        }
188        #[cfg(not(any(target_os = "windows", target_os = "macos")))]
189        {
190            Self::Fsync
191        }
192    }
193
194    /// Map a `core.fsyncMethod` value; unknown or absent values fall back to
195    /// the platform default (matching `ReferenceFsyncMethod::from_config`).
196    pub fn from_config(value: Option<&str>) -> Self {
197        match value {
198            Some("fsync") => Self::Fsync,
199            Some("writeout-only") => Self::WriteoutOnly,
200            Some("batch") => Self::Batch,
201            _ => Self::platform_default(),
202        }
203    }
204
205    /// Apply this method's barrier to an open file handle. Mirrors the
206    /// `maybe_fsync` dispatch used by the reference store: `writeout-only`
207    /// maps to `sync_data`, the other methods to `sync_all`.
208    pub fn apply(self, file: &fs::File) -> io::Result<()> {
209        match self {
210            Self::WriteoutOnly => file.sync_data(),
211            Self::Fsync | Self::Batch => file.sync_all(),
212        }
213    }
214}
215
216/// Whether `GIT_TEST_FSYNC` permits real barriers. Upstream reads this as a
217/// boolean through `git_env_bool("GIT_TEST_FSYNC", 1)` inside `maybe_fsync`;
218/// unrecognized spellings leave syncing enabled.
219pub fn test_fsync_enabled() -> bool {
220    let Ok(value) = env::var("GIT_TEST_FSYNC") else {
221        return true;
222    };
223    !matches!(
224        value.to_ascii_lowercase().as_str(),
225        "0" | "false" | "no" | "off" | ""
226    )
227}
228
229/// Minimal read-only config lookup surface accepted by [`Policy::resolve`].
230///
231/// Defined here — not as a concrete `GitConfig` parameter — because
232/// `sley-config` depends on this crate, not the other way around. The config
233/// crate implements this over `GitConfig::get`, so callers holding resolved
234/// configuration pass it directly to [`Policy::resolve`].
235pub trait FsyncConfigSource {
236    /// Last value of `<section>.<subsection?>.<key>` (case-normalized
237    /// lookup), or `None` when unset.
238    fn fsync_lookup(&self, section: &str, subsection: Option<&str>, key: &str) -> Option<&str>;
239}
240
241/// Resolved durability policy for repository writes.
242///
243/// Combines the component selection from `core.fsync`, the barrier choice
244/// from `core.fsyncMethod`, and the `GIT_TEST_FSYNC` gate, in the shape of
245/// upstream's `fsync_components` + `fsync_method` + `use_fsync` globals.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct Policy {
248    components: FsyncComponents,
249    method: FsyncMethod,
250    use_fsync: bool,
251}
252
253impl Default for Policy {
254    fn default() -> Self {
255        Self::from_values(None, None)
256    }
257}
258
259impl Policy {
260    /// Resolve from raw `core.fsync` / `core.fsyncMethod` values. Absent
261    /// values leave the corresponding platform default in place.
262    pub fn from_values(core_fsync: Option<&str>, core_fsync_method: Option<&str>) -> Self {
263        Self {
264            components: core_fsync.map_or(FsyncComponents::PLATFORM_DEFAULT, FsyncComponents::parse),
265            method: FsyncMethod::from_config(core_fsync_method),
266            use_fsync: test_fsync_enabled(),
267        }
268    }
269
270    /// Resolve from parsed configuration (e.g. `GitConfig`). Configuration
271    /// lookup failures should fall back to [`Policy::default`], which matches
272    /// reading a repository without `core.fsync` keys.
273    pub fn resolve(config: &impl FsyncConfigSource) -> Self {
274        Self::from_values(
275            config.fsync_lookup("core", None, "fsync"),
276            config.fsync_lookup("core", None, "fsyncMethod"),
277        )
278    }
279
280    /// Apply command-line-style overrides (`git -c core.fsync=...`): `Some`
281    /// values replace the corresponding setting, `None` keeps it, and the
282    /// test switch is re-read afterwards so callers holding a long-lived
283    /// policy observe current environment state.
284    pub fn overridden(mut self, core_fsync: Option<&str>, core_fsync_method: Option<&str>) -> Self {
285        if let Some(value) = core_fsync {
286            self.components = FsyncComponents::parse(value);
287        }
288        if let Some(value) = core_fsync_method {
289            self.method = FsyncMethod::from_config(Some(value));
290        }
291        self.use_fsync = test_fsync_enabled();
292        self
293    }
294
295    /// Component set after `core.fsync` parsing.
296    pub const fn components(&self) -> FsyncComponents {
297        self.components
298    }
299
300    /// Effective barrier method.
301    pub const fn method(&self) -> FsyncMethod {
302        self.method
303    }
304
305    /// Whether writes to `component` take a barrier under this policy
306    /// (upstream `fsync_component()`'s gating, including the test switch).
307    pub const fn syncs(&self, component: FsyncComponents) -> bool {
308        self.use_fsync && self.components.contains(component)
309    }
310
311    /// The barrier method to apply when writing `component` files, or `None`
312    /// when no barrier applies. This is the shape threaded through locked
313    /// write paths that sync before rename.
314    pub const fn method_if_enabled(&self, component: FsyncComponents) -> Option<FsyncMethod> {
315        if self.syncs(component) {
316            Some(self.method)
317        } else {
318            None
319        }
320    }
321
322    /// Sync an open file handle when `component` is covered by this policy;
323    /// otherwise return without touching the handle. Errors surface verbatim.
324    pub fn apply(&self, file: &fs::File, component: FsyncComponents) -> io::Result<()> {
325        match self.method_if_enabled(component) {
326            Some(method) => method.apply(file),
327            None => Ok(()),
328        }
329    }
330}
331
332/// Open `path` for writing (no truncate, no create) and apply `policy`'s
333/// barrier for `component`. Convenience for post-hoc hardening of already
334/// published files; write paths that hold the handle open should prefer
335/// [`Policy::apply`] directly. Requires write access because `sync_all`
336/// degrades to a permission error on read-only handles (Windows
337/// `FlushFileBuffers` semantics).
338pub fn sync_file(path: &Path, policy: &Policy, component: FsyncComponents) -> io::Result<()> {
339    let file = fs::OpenOptions::new().write(true).open(path)?;
340    policy.apply(&file, component)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn component_bits_match_upstream_layout() {
349        assert_eq!(FsyncComponents::OBJECTS.bits(), 0b0000_0011);
350        assert_eq!(FsyncComponents::DERIVED_METADATA.bits(), 0b0000_1100);
351        // DEFAULT = (objects | derived-metadata) & ~loose-object.
352        assert_eq!(
353            FsyncComponents::DEFAULT.bits(),
354            FsyncComponents::PACK.bits()
355                | FsyncComponents::PACK_METADATA.bits()
356                | FsyncComponents::COMMIT_GRAPH.bits()
357        );
358        assert_eq!(
359            FsyncComponents::COMMITTED.bits(),
360            FsyncComponents::OBJECTS.union(FsyncComponents::REFERENCE).bits()
361        );
362        assert_eq!(
363            FsyncComponents::ADDED.bits(),
364            FsyncComponents::COMMITTED
365                .union(FsyncComponents::INDEX)
366                .bits()
367        );
368        assert_eq!(FsyncComponents::ALL.bits(), 0b0111_1111);
369    }
370
371    #[test]
372    fn parse_matches_upstream_groups_negation_and_prefixing() {
373        let reference = FsyncComponents::REFERENCE;
374        assert!(!FsyncComponents::parse("none").contains(reference));
375        // `none` clears the base but later components still accumulate.
376        assert!(FsyncComponents::parse("none,reference").contains(reference));
377        assert!(!FsyncComponents::parse("none,-reference").contains(reference));
378        assert!(!FsyncComponents::parse("objects,index").contains(reference));
379        assert!(!FsyncComponents::parse("-reference").contains(reference));
380        for value in ["reference", "ref", "committed", "added", "all"] {
381            assert!(
382                FsyncComponents::parse(value).contains(reference),
383                "{value} must include references"
384            );
385        }
386        // Positives win over accumulated negatives in either order.
387        assert!(FsyncComponents::parse("reference,-reference").contains(reference));
388        assert!(FsyncComponents::parse("-reference,reference").contains(reference));
389        // `none` resets the running base; earlier positives still apply.
390        assert!(FsyncComponents::parse("reference,none").contains(reference));
391        assert!(FsyncComponents::parse(
392            "committed,-loose-object"
393        )
394        .contains(reference));
395        // Prefix matching reaches aggregate rows: upstream's strncmp scan
396        // makes "pack" also select pack-metadata.
397        assert!(FsyncComponents::parse("pack").contains(FsyncComponents::PACK_METADATA));
398        // Unknown components are ignored.
399        assert_eq!(
400            FsyncComponents::parse("nonsense").bits(),
401            FsyncComponents::PLATFORM_DEFAULT.bits()
402        );
403    }
404
405    #[test]
406    fn policy_gating_honors_components_and_test_switch() {
407        let enabled = Policy::from_values(Some("reference"), Some("writeout-only"));
408        assert!(enabled.syncs(FsyncComponents::REFERENCE) || !test_fsync_enabled());
409        if test_fsync_enabled() {
410            assert_eq!(
411                enabled.method_if_enabled(FsyncComponents::REFERENCE),
412                Some(FsyncMethod::WriteoutOnly)
413            );
414            assert_eq!(enabled.method_if_enabled(FsyncComponents::INDEX), None);
415        }
416
417        let disabled = Policy::from_values(Some("none"), Some("fsync"));
418        assert_eq!(disabled.method_if_enabled(FsyncComponents::REFERENCE), None);
419
420        // Absent core.fsync leaves the platform default, which excludes
421        // references on every git v2.55 platform.
422        let default = Policy::from_values(None, None);
423        assert!(!default.components().contains(FsyncComponents::REFERENCE));
424
425        // Overrides replace only the provided values.
426        let overridden = disabled.overridden(None, Some("batch"));
427        assert_eq!(overridden.method(), FsyncMethod::Batch);
428        let flipped = default.overridden(Some("all"), None);
429        if test_fsync_enabled() {
430            assert_eq!(
431                flipped.method_if_enabled(FsyncComponents::REFERENCE),
432                Some(FsyncMethod::platform_default())
433            );
434        }
435    }
436}