Skip to main content

aft/views/
mod.rs

1//! Per-checkout view manifests and durable generation publication.
2//!
3//! Blob stores are family-scoped, while this module owns the checkout-scoped
4//! assembly that names one immutable manifest per generation.  The only durable
5//! membership representation is the manifest itself; closure inputs remain
6//! transient while a generation is published.
7
8use base64::{engine::general_purpose::STANDARD, Engine as _};
9use rusqlite::{params, Connection, TransactionBehavior};
10use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
11pub mod assembly;
12mod generation;
13pub(crate) mod io;
14pub mod materialization;
15mod profile;
16pub(crate) mod read;
17
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19use std::fmt;
20use std::fs::{self, File, OpenOptions};
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::sync::{Mutex, OnceLock};
24use std::time::Duration;
25
26/// Version number for the byte encoding used to identify manifest paths.
27/// Increment it whenever that encoding changes.
28pub const PATH_IDENTITY_VERSION: u8 = 1;
29const POINTER_DATABASE: &str = "pointer.sqlite";
30const POINTER_BUSY_TIMEOUT: Duration = Duration::from_millis(5_000);
31const FILE_OPEN_RETRY_TIMEOUT: Duration = Duration::from_millis(5_000);
32
33static HEAD_FINGERPRINTS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
34
35pub(crate) fn cache_head_fingerprint(root: PathBuf, fingerprint: String) {
36    HEAD_FINGERPRINTS
37        .get_or_init(|| Mutex::new(HashMap::new()))
38        .lock()
39        .unwrap_or_else(std::sync::PoisonError::into_inner)
40        .insert(crate::path_identity::project_scope_key(&root), fingerprint);
41}
42
43pub(crate) fn cached_head_fingerprint(root: &Path) -> Option<String> {
44    HEAD_FINGERPRINTS
45        .get_or_init(|| Mutex::new(HashMap::new()))
46        .lock()
47        .unwrap_or_else(std::sync::PoisonError::into_inner)
48        .get(&crate::path_identity::project_scope_key(root))
49        .cloned()
50}
51
52pub(crate) fn generation_matches_head(generation: &str, head_fingerprint: &str) -> bool {
53    !head_fingerprint.is_empty() && generation.ends_with(head_fingerprint)
54}
55
56pub(crate) fn resolve_derived_path(view_dir: &Path, generation: &str) -> Result<PathBuf> {
57    ViewStore {
58        view_dir: view_dir.to_path_buf(),
59    }
60    .derived_path(generation)
61}
62
63/// Errors raised while constructing, verifying, or publishing a view.
64#[derive(Debug)]
65pub enum ViewError {
66    Io(std::io::Error),
67    Sqlite(rusqlite::Error),
68    Json(serde_json::Error),
69    InvalidManifest(String),
70    ManifestAlreadyExists(String),
71    MissingBlob { plane: ArtifactPlane, key: String },
72    MissingTrigram,
73    MissingAlias(String),
74}
75
76impl fmt::Display for ViewError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Io(error) => write!(formatter, "view I/O failed: {error}"),
80            Self::Sqlite(error) => write!(formatter, "view SQLite operation failed: {error}"),
81            Self::Json(error) => write!(formatter, "view manifest JSON failed: {error}"),
82            Self::InvalidManifest(message) => write!(formatter, "invalid view manifest: {message}"),
83            Self::ManifestAlreadyExists(generation) => {
84                write!(
85                    formatter,
86                    "a manifest already exists for generation {generation}"
87                )
88            }
89            Self::MissingBlob { plane, key } => {
90                write!(
91                    formatter,
92                    "manifest references missing {plane:?} blob {key}"
93                )
94            }
95            Self::MissingTrigram => write!(
96                formatter,
97                "published generation is missing its trigram state"
98            ),
99            Self::MissingAlias(oid) => write!(
100                formatter,
101                "published generation references missing alias {oid}"
102            ),
103        }
104    }
105}
106
107impl std::error::Error for ViewError {
108    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
109        match self {
110            Self::Io(error) => Some(error),
111            Self::Sqlite(error) => Some(error),
112            Self::Json(error) => Some(error),
113            _ => None,
114        }
115    }
116}
117
118impl From<std::io::Error> for ViewError {
119    fn from(error: std::io::Error) -> Self {
120        Self::Io(error)
121    }
122}
123
124impl From<rusqlite::Error> for ViewError {
125    fn from(error: rusqlite::Error) -> Self {
126        Self::Sqlite(error)
127    }
128}
129
130impl From<serde_json::Error> for ViewError {
131    fn from(error: serde_json::Error) -> Self {
132        Self::Json(error)
133    }
134}
135
136/// Result type used by the view publication API.
137pub type Result<T> = std::result::Result<T, ViewError>;
138
139/// A byte-exact manifest value. JSON uses a UTF-8 string when possible and a
140/// `{"b64": ...}` object otherwise, so no path bytes are lost at the boundary.
141#[derive(Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
142pub struct ByteString(Vec<u8>);
143
144impl ByteString {
145    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
146        Self(bytes.into())
147    }
148
149    pub fn as_bytes(&self) -> &[u8] {
150        &self.0
151    }
152
153    pub fn into_bytes(self) -> Vec<u8> {
154        self.0
155    }
156}
157
158impl From<Vec<u8>> for ByteString {
159    fn from(bytes: Vec<u8>) -> Self {
160        Self::new(bytes)
161    }
162}
163
164impl From<&[u8]> for ByteString {
165    fn from(bytes: &[u8]) -> Self {
166        Self::new(bytes)
167    }
168}
169
170impl Serialize for ByteString {
171    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
172    where
173        S: Serializer,
174    {
175        match std::str::from_utf8(&self.0) {
176            Ok(text) => serializer.serialize_str(text),
177            Err(_) => {
178                let mut encoded = BTreeMap::new();
179                encoded.insert("b64", STANDARD.encode(&self.0));
180                encoded.serialize(serializer)
181            }
182        }
183    }
184}
185
186impl<'de> Deserialize<'de> for ByteString {
187    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
188    where
189        D: Deserializer<'de>,
190    {
191        let value = serde_json::Value::deserialize(deserializer)?;
192        match value {
193            serde_json::Value::String(text) => Ok(Self::new(text.into_bytes())),
194            serde_json::Value::Object(mut object) if object.len() == 1 => {
195                let encoded = object
196                    .remove("b64")
197                    .and_then(|value| value.as_str().map(str::to_owned))
198                    .ok_or_else(|| {
199                        D::Error::custom("byte string object must contain only string b64")
200                    })?;
201                STANDARD
202                    .decode(encoded)
203                    .map(Self::new)
204                    .map_err(D::Error::custom)
205            }
206            _ => Err(D::Error::custom(
207                "byte string must be a UTF-8 string or an object with b64",
208            )),
209        }
210    }
211}
212
213/// A relative path key stored as exact bytes in bytewise canonical order.
214#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
215pub struct RelPath(ByteString);
216
217impl RelPath {
218    /// Creates a manifest key from slash-separated relative path bytes.
219    pub fn new(bytes: impl Into<Vec<u8>>) -> Result<Self> {
220        let bytes = bytes.into();
221        validate_filesystem_rel_path(&bytes)?;
222        Ok(Self(ByteString::new(bytes)))
223    }
224
225    /// Creates the reserved key for a manifest-only synthetic member.
226    pub fn synthetic(name: &str) -> Result<Self> {
227        if name.is_empty() || name.as_bytes().contains(&0) {
228            return Err(ViewError::InvalidManifest(
229                "synthetic entry names must be non-empty and contain no NUL".to_string(),
230            ));
231        }
232        let mut key = Vec::with_capacity(name.len() + 1);
233        key.push(0);
234        key.extend_from_slice(name.as_bytes());
235        Ok(Self(ByteString::new(key)))
236    }
237
238    /// Converts an OS path to the slash-separated byte identity used in a manifest.
239    pub fn from_os_path(path: &Path) -> Result<Self> {
240        if path.is_absolute() {
241            return Err(ViewError::InvalidManifest(
242                "rel_path must not be absolute".to_string(),
243            ));
244        }
245
246        #[cfg(unix)]
247        let bytes = {
248            use std::os::unix::ffi::OsStrExt as _;
249            path.as_os_str().as_bytes().to_vec()
250        };
251        #[cfg(windows)]
252        let bytes: Vec<u8> = path
253            .as_os_str()
254            .as_encoded_bytes()
255            .iter()
256            .map(|byte| if *byte == b'\\' { b'/' } else { *byte })
257            .collect();
258        #[cfg(all(not(unix), not(windows)))]
259        let bytes = path.as_os_str().as_encoded_bytes().to_vec();
260
261        Self::new(bytes)
262    }
263
264    pub fn as_bytes(&self) -> &[u8] {
265        self.0.as_bytes()
266    }
267
268    pub fn is_synthetic(&self) -> bool {
269        self.0.as_bytes().first() == Some(&0)
270    }
271}
272
273impl Serialize for RelPath {
274    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
275    where
276        S: Serializer,
277    {
278        self.0.serialize(serializer)
279    }
280}
281
282impl<'de> Deserialize<'de> for RelPath {
283    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
284    where
285        D: Deserializer<'de>,
286    {
287        let bytes = ByteString::deserialize(deserializer)?.into_bytes();
288        if bytes.first() == Some(&0) {
289            if bytes.len() == 1 || bytes[1..].contains(&0) {
290                return Err(D::Error::custom("invalid synthetic rel_path"));
291            }
292            Ok(Self(ByteString::new(bytes)))
293        } else {
294            Self::new(bytes).map_err(D::Error::custom)
295        }
296    }
297}
298
299/// One of the content-addressed artifact planes referenced by a manifest.
300#[derive(Clone, Copy, Debug, Eq, PartialEq)]
301pub enum ArtifactPlane {
302    Semantic,
303    Callgraph,
304}
305
306/// Full keys for a regular file's per-file artifacts.
307#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct RegularPlanes {
309    pub semantic: Option<String>,
310    pub callgraph: Option<String>,
311}
312
313/// Full keys for a synthetic member. Synthetic state participates only in the
314/// callgraph join, so it cannot accidentally acquire a semantic key.
315#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
316pub struct SyntheticPlanes {
317    pub callgraph: String,
318}
319
320/// The tagged union stored for every manifest member. Each entry serializes a
321/// `kind` field that identifies its variant.
322#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
323#[serde(tag = "kind", rename_all = "snake_case")]
324pub enum ManifestEntry {
325    Regular {
326        mode: u32,
327        planes: RegularPlanes,
328        resolution_input: bool,
329    },
330    Symlink {
331        target_bytes: ByteString,
332    },
333    Gitlink {
334        oid: String,
335    },
336    Synthetic {
337        name: String,
338        planes: SyntheticPlanes,
339    },
340}
341
342/// The variant name used for member-by-member manifest assertions.
343#[derive(Clone, Copy, Debug, Eq, PartialEq)]
344pub enum ManifestEntryKind {
345    Regular,
346    Symlink,
347    Gitlink,
348    Synthetic,
349}
350
351impl ManifestEntry {
352    pub fn kind(&self) -> ManifestEntryKind {
353        match self {
354            Self::Regular { .. } => ManifestEntryKind::Regular,
355            Self::Symlink { .. } => ManifestEntryKind::Symlink,
356            Self::Gitlink { .. } => ManifestEntryKind::Gitlink,
357            Self::Synthetic { .. } => ManifestEntryKind::Synthetic,
358        }
359    }
360}
361
362/// The one manifest that defines a view generation.
363#[derive(Clone, Debug, Eq, PartialEq)]
364pub struct Manifest {
365    pub path_identity_version: u8,
366    entries: BTreeMap<RelPath, ManifestEntry>,
367}
368
369impl Manifest {
370    pub fn new(entries: impl IntoIterator<Item = (RelPath, ManifestEntry)>) -> Result<Self> {
371        let mut manifest = Self {
372            path_identity_version: PATH_IDENTITY_VERSION,
373            entries: BTreeMap::new(),
374        };
375        for (rel_path, entry) in entries {
376            manifest.insert(rel_path, entry)?;
377        }
378        Ok(manifest)
379    }
380
381    pub fn insert(&mut self, rel_path: RelPath, entry: ManifestEntry) -> Result<()> {
382        validate_member(&rel_path, &entry)?;
383        if self.entries.insert(rel_path, entry).is_some() {
384            return Err(ViewError::InvalidManifest(
385                "a manifest cannot contain the same rel_path twice".to_string(),
386            ));
387        }
388        Ok(())
389    }
390
391    pub fn entries(&self) -> impl Iterator<Item = (&RelPath, &ManifestEntry)> {
392        self.entries.iter()
393    }
394
395    pub fn get(&self, rel_path: &RelPath) -> Option<&ManifestEntry> {
396        self.entries.get(rel_path)
397    }
398
399    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
400        Ok(serde_json::to_vec(self)?)
401    }
402
403    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
404        Ok(serde_json::from_slice(bytes)?)
405    }
406
407    fn plane_keys(&self) -> impl Iterator<Item = (ArtifactPlane, &str)> {
408        self.entries.values().flat_map(|entry| match entry {
409            ManifestEntry::Regular { planes, .. } => [
410                planes
411                    .semantic
412                    .as_deref()
413                    .map(|key| (ArtifactPlane::Semantic, key)),
414                planes
415                    .callgraph
416                    .as_deref()
417                    .map(|key| (ArtifactPlane::Callgraph, key)),
418            ]
419            .into_iter()
420            .flatten()
421            .collect::<Vec<_>>(),
422            ManifestEntry::Synthetic { planes, .. } => {
423                vec![(ArtifactPlane::Callgraph, planes.callgraph.as_str())]
424            }
425            ManifestEntry::Symlink { .. } | ManifestEntry::Gitlink { .. } => Vec::new(),
426        })
427    }
428}
429
430#[derive(Deserialize, Serialize)]
431struct JsonManifestEntry {
432    rel_path: RelPath,
433    #[serde(flatten)]
434    entry: ManifestEntry,
435}
436
437#[derive(Deserialize, Serialize)]
438struct JsonManifest {
439    path_identity_version: u8,
440    entries: Vec<JsonManifestEntry>,
441}
442
443impl Serialize for Manifest {
444    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
445    where
446        S: Serializer,
447    {
448        let entries = self
449            .entries
450            .iter()
451            .map(|(rel_path, entry)| JsonManifestEntry {
452                rel_path: rel_path.clone(),
453                entry: entry.clone(),
454            })
455            .collect();
456        JsonManifest {
457            path_identity_version: self.path_identity_version,
458            entries,
459        }
460        .serialize(serializer)
461    }
462}
463
464impl<'de> Deserialize<'de> for Manifest {
465    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
466    where
467        D: Deserializer<'de>,
468    {
469        let json = JsonManifest::deserialize(deserializer)?;
470        if json.path_identity_version != PATH_IDENTITY_VERSION {
471            return Err(D::Error::custom(format!(
472                "unsupported path_identity_version {}",
473                json.path_identity_version
474            )));
475        }
476        Self::new(
477            json.entries
478                .into_iter()
479                .map(|member| (member.rel_path, member.entry)),
480        )
481        .map_err(D::Error::custom)
482    }
483}
484
485/// A transient list of aliases that a derived generation references. It is not
486/// serialized beside the manifest, preventing a side closure table from becoming
487/// a second membership authority.
488#[derive(Clone, Debug, Default, Eq, PartialEq)]
489pub struct ClosureRequirements {
490    pub referenced_aliases: BTreeSet<String>,
491}
492
493/// Presence checks used by durable restart and manifest-membership validation.
494pub trait PublicationClosure {
495    fn contains_blob(&self, plane: ArtifactPlane, full_key: &str) -> Result<bool>;
496    fn probe_blobs(&self, keys: &[(ArtifactPlane, &str)]) -> Result<()> {
497        for &(plane, key) in keys {
498            if !self.contains_blob(plane, key)? {
499                return Err(ViewError::MissingBlob {
500                    plane,
501                    key: key.to_owned(),
502                });
503            }
504        }
505        Ok(())
506    }
507    fn trigram_is_present(&self) -> Result<bool>;
508    fn contains_alias(&self, git_oid: &str) -> Result<bool>;
509}
510
511/// Ensures that a generation never publishes a manifest pointing at incomplete
512/// blob, trigram, or alias state.
513pub fn probe_publication_closure(
514    manifest: &Manifest,
515    requirements: &ClosureRequirements,
516    closure: &impl PublicationClosure,
517) -> Result<()> {
518    closure.probe_blobs(&manifest.plane_keys().collect::<Vec<_>>())?;
519    if !closure.trigram_is_present()? {
520        return Err(ViewError::MissingTrigram);
521    }
522    for oid in &requirements.referenced_aliases {
523        if !closure.contains_alias(oid)? {
524            return Err(ViewError::MissingAlias(oid.clone()));
525        }
526    }
527    Ok(())
528}
529
530/// Files whose committed state must be durable before a pointer can name a new
531/// generation. Shared SQLite stores are checkpointed before fsync; the derived
532/// generation keeps its commit-durable WAL until detached post-publication work
533/// checkpoints it for the next clone.
534#[derive(Clone, Debug)]
535pub struct PublicationArtifacts {
536    pub blob_databases: Vec<PathBuf>,
537    pub derived_database: PathBuf,
538    pub trigram_artifact: PathBuf,
539    pub alias_database: PathBuf,
540}
541
542/// Input to a single manifest publication attempt.
543#[derive(Clone, Debug)]
544pub struct PublicationRequest<'a> {
545    pub generation: &'a str,
546    /// `None` is the empty initial pointer. A conflict reports the winning base
547    /// so callers can re-derive and retry without overwriting it.
548    pub base_generation: Option<&'a str>,
549    pub manifest: &'a Manifest,
550    pub artifacts: PublicationArtifacts,
551    pub closure_requirements: ClosureRequirements,
552}
553
554/// Observable publication stages, primarily for failpoint and durability-order
555/// tests. Production callers do not need to retain the sequence.
556#[derive(Clone, Copy, Debug, Eq, PartialEq)]
557pub enum PublicationStep {
558    /// One blob database's SQLite WAL checkpoint completed; fsync has not begun.
559    BlobWalCheckpointed,
560    /// One blob database's main file, WAL, and parent have been fsynced.
561    BlobWalFsync,
562    /// The derived database's committed WAL and main file have been fsynced
563    /// without moving WAL pages into the main file.
564    DerivedWalFsynced,
565    DerivedAndTrigramDurable,
566    AliasRowsDurable,
567    ClosureProbed,
568    ManifestFileWritten,
569    ManifestParentFsynced,
570    PointerCas,
571    PointerCheckpointed,
572    PointerDatabaseFsynced,
573    PointerDirectoryFsynced,
574}
575
576pub trait PublicationObserver {
577    fn reached(&self, step: PublicationStep);
578}
579
580/// The result of comparing a generation's base pointer in SQLite.
581#[derive(Clone, Debug, Eq, PartialEq)]
582pub enum PublishOutcome {
583    Published,
584    Conflict { current_generation: Option<String> },
585}
586
587/// An immutable, durable generation waiting for its short pointer transaction.
588/// Construction is private so callers cannot expose an unprepared manifest.
589#[derive(Debug)]
590pub struct PreparedPublication {
591    store: ViewStore,
592    generation: String,
593    base_generation: Option<String>,
594    pointer: Connection,
595}
596
597impl PreparedPublication {
598    pub fn commit(mut self) -> Result<PublishOutcome> {
599        // FULL synchronizes the commit record in the pointer WAL. No artifact
600        // checkpoint, manifest scan, or directory traversal belongs in this gate.
601        let transaction = self
602            .pointer
603            .transaction_with_behavior(TransactionBehavior::Immediate)?;
604        let updated = transaction.execute(
605            "UPDATE pointer SET generation = ?1 WHERE generation = ?2",
606            params![
607                self.generation,
608                self.base_generation.as_deref().unwrap_or_default()
609            ],
610        )?;
611        let outcome = if updated == 1 {
612            PublishOutcome::Published
613        } else {
614            let current: String = transaction.query_row(
615                "SELECT generation FROM pointer WHERE singleton = 1",
616                [],
617                |row| row.get(0),
618            )?;
619            PublishOutcome::Conflict {
620                current_generation: (!current.is_empty()).then_some(current),
621            }
622        };
623        transaction.commit()?;
624        Ok(outcome)
625    }
626
627    fn commit_with_observer(
628        self,
629        observer: Option<&dyn PublicationObserver>,
630    ) -> Result<PublishOutcome> {
631        self.store
632            .commit_prepared(&self.generation, self.base_generation.as_deref(), observer)
633    }
634}
635
636/// SQLite settings required for the per-view pointer connection.
637#[derive(Clone, Debug, Eq, PartialEq)]
638pub struct PointerPragmas {
639    pub journal_mode: String,
640    pub synchronous: i64,
641    pub busy_timeout_ms: i64,
642    pub foreign_keys: i64,
643}
644
645/// Checkout-scoped storage for immutable manifests and the SQLite current pointer.
646#[derive(Clone, Debug)]
647pub struct ViewStore {
648    view_dir: PathBuf,
649}
650
651impl ViewStore {
652    /// Opens `<storage>/views/<project_scope_key>` and initializes its singleton
653    /// pointer row under `BEGIN IMMEDIATE` so concurrent first opens agree.
654    pub fn open(storage: impl AsRef<Path>, project_scope_key: &str) -> Result<Self> {
655        validate_scope_key(project_scope_key)?;
656        let view_dir = storage.as_ref().join("views").join(project_scope_key);
657        fs::create_dir_all(&view_dir)?;
658        let store = Self { view_dir };
659        store.initialize_pointer()?;
660        Ok(store)
661    }
662
663    pub fn view_dir(&self) -> &Path {
664        &self.view_dir
665    }
666
667    pub fn pointer_path(&self) -> PathBuf {
668        self.view_dir.join(POINTER_DATABASE)
669    }
670
671    pub fn manifest_path(&self, generation: &str) -> Result<PathBuf> {
672        validate_generation(generation)?;
673        Ok(self.view_dir.join(format!("manifest-{generation}.json")))
674    }
675
676    pub fn current_generation(&self) -> Result<Option<String>> {
677        let connection = self.open_pointer_connection()?;
678        let generation: String = connection.query_row(
679            "SELECT generation FROM pointer WHERE singleton = 1",
680            [],
681            |row| row.get(0),
682        )?;
683        Ok((!generation.is_empty()).then_some(generation))
684    }
685
686    /// Reads the settings installed on a new pointer connection. SQLite keeps
687    /// `synchronous`, `busy_timeout`, and `foreign_keys` per connection, so this
688    /// check must use the same open path as publication rather than a raw handle.
689    pub fn pointer_pragmas(&self) -> Result<PointerPragmas> {
690        let connection = self.open_pointer_connection()?;
691        Ok(PointerPragmas {
692            journal_mode: connection.query_row("PRAGMA journal_mode", [], |row| row.get(0))?,
693            synchronous: connection.query_row("PRAGMA synchronous", [], |row| row.get(0))?,
694            busy_timeout_ms: connection.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?,
695            foreign_keys: connection.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?,
696        })
697    }
698
699    pub fn load_manifest(&self, generation: &str) -> Result<Manifest> {
700        Ok(Manifest::from_json_bytes(&fs::read(
701            self.manifest_path(generation)?,
702        )?)?)
703    }
704
705    /// Publishes a fully durable generation. The manifest is written exactly once;
706    /// a lost pointer race leaves that immutable manifest unreferenced so a caller
707    /// can safely re-derive from the winner without losing either update.
708    pub fn publish(
709        &self,
710        request: &PublicationRequest<'_>,
711        closure: &impl PublicationClosure,
712    ) -> Result<PublishOutcome> {
713        self.publish_with_observer(request, closure, None)
714    }
715
716    pub fn publish_with_observer(
717        &self,
718        request: &PublicationRequest<'_>,
719        closure: &impl PublicationClosure,
720        observer: Option<&dyn PublicationObserver>,
721    ) -> Result<PublishOutcome> {
722        let prepared = self.prepare_with_observer(request, closure, observer)?;
723        prepared.commit_with_observer(observer)
724    }
725
726    /// Performs closure validation and all generation-sized durability work without
727    /// changing the pointer. The returned token is the only input needed by CAS.
728    pub fn prepare_with_observer(
729        &self,
730        request: &PublicationRequest<'_>,
731        closure: &impl PublicationClosure,
732        observer: Option<&dyn PublicationObserver>,
733    ) -> Result<PreparedPublication> {
734        self.prepare_inner(request, closure, observer, None)
735    }
736
737    pub(super) fn prepare_with_reused_derived(
738        &self,
739        request: &PublicationRequest<'_>,
740        closure: &impl PublicationClosure,
741        previous: Option<&Manifest>,
742    ) -> Result<PreparedPublication> {
743        self.prepare_inner(request, closure, None, previous)
744    }
745
746    fn prepare_inner(
747        &self,
748        request: &PublicationRequest<'_>,
749        closure: &impl PublicationClosure,
750        observer: Option<&dyn PublicationObserver>,
751        reused: Option<&Manifest>,
752    ) -> Result<PreparedPublication> {
753        validate_generation(request.generation)?;
754        if request.manifest.path_identity_version != PATH_IDENTITY_VERSION {
755            return Err(ViewError::InvalidManifest(
756                "manifest has an unsupported path identity version".to_string(),
757            ));
758        }
759        let manifest_path = self.manifest_path(request.generation)?;
760        if manifest_path.exists() {
761            return Err(ViewError::ManifestAlreadyExists(
762                request.generation.to_string(),
763            ));
764        }
765
766        let mut timing = profile::PublicationTiming::new(&self.view_dir);
767        let blob_durability_barrier = crate::blob_store::publication_durability_barrier();
768        timing.phase("closure_lock");
769        for path in &request.artifacts.blob_databases {
770            if !crate::blob_store::blob_database_needs_durability(path) {
771                continue;
772            }
773            checkpoint_and_sync_database(path, observer, true)?;
774            crate::blob_store::mark_blob_database_durable(path);
775        }
776
777        timing.phase("blob_durability");
778        // A semantic-only fill reuses the pinned base's derived database, which
779        // is already durable; syncing it again would be the closure cost the fill
780        // exists to avoid.
781        if reused.is_none() {
782            sync_database_wal_without_checkpoint(&request.artifacts.derived_database, observer)?;
783        }
784        timing.phase("derived_durability");
785        sync_file_and_parent(&request.artifacts.trigram_artifact)?;
786        observe(observer, PublicationStep::DerivedAndTrigramDurable);
787        timing.phase("trigram_durability");
788
789        if reused.is_none() {
790            checkpoint_and_sync_database(&request.artifacts.alias_database, observer, false)?;
791        }
792        observe(observer, PublicationStep::AliasRowsDurable);
793        timing.phase("alias_durability");
794
795        if let Some(previous) = reused {
796            // The pinned base already proved closure for immutable shared blobs.
797            // A semantic-only fill must validate just the newly referenced keys.
798            let old_keys = previous
799                .plane_keys()
800                .map(|(plane, key)| (plane == ArtifactPlane::Semantic, key))
801                .collect::<BTreeSet<_>>();
802            let new_keys = request
803                .manifest
804                .plane_keys()
805                .filter(|(plane, key)| {
806                    !old_keys.contains(&(*plane == ArtifactPlane::Semantic, *key))
807                })
808                .collect::<Vec<_>>();
809            closure.probe_blobs(&new_keys)?;
810            if !closure.trigram_is_present()? {
811                return Err(ViewError::MissingTrigram);
812            }
813        } else {
814            probe_publication_closure(request.manifest, &request.closure_requirements, closure)?;
815        }
816        observe(observer, PublicationStep::ClosureProbed);
817        drop(blob_durability_barrier);
818        timing.phase("closure_probe");
819
820        write_manifest_once(&manifest_path, request.manifest)?;
821        observe(observer, PublicationStep::ManifestFileWritten);
822        sync_directory(&self.view_dir)?;
823        observe(observer, PublicationStep::ManifestParentFsynced);
824        timing.phase("manifest_durability");
825
826        let pointer = self.open_pointer_connection()?;
827        pointer.pragma_update(None, "synchronous", "FULL")?;
828        pointer.busy_timeout(Duration::from_millis(20))?;
829        sync_directory(&self.view_dir)?;
830        timing.phase("pointer_prepare");
831        Ok(PreparedPublication {
832            pointer,
833            store: self.clone(),
834            generation: request.generation.to_owned(),
835            base_generation: request.base_generation.map(str::to_owned),
836        })
837    }
838
839    fn commit_prepared(
840        &self,
841        generation: &str,
842        base_generation: Option<&str>,
843        observer: Option<&dyn PublicationObserver>,
844    ) -> Result<PublishOutcome> {
845        let outcome =
846            self.compare_and_swap_pointer(generation, base_generation.unwrap_or_default())?;
847        observe(observer, PublicationStep::PointerCas);
848
849        if outcome == PublishOutcome::Published {
850            checkpoint_pointer_after_cas(&self.pointer_path())?;
851            observe(observer, PublicationStep::PointerCheckpointed);
852            sync_file(&self.pointer_path())?;
853            observe(observer, PublicationStep::PointerDatabaseFsynced);
854            sync_directory(&self.view_dir)?;
855            observe(observer, PublicationStep::PointerDirectoryFsynced);
856        }
857        Ok(outcome)
858    }
859
860    fn initialize_pointer(&self) -> Result<()> {
861        let connection = self.open_pointer_connection()?;
862        connection.execute_batch(
863            "BEGIN IMMEDIATE;
864             CREATE TABLE IF NOT EXISTS pointer (
865                 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
866                 generation TEXT NOT NULL
867             );
868             INSERT OR IGNORE INTO pointer (singleton, generation) VALUES (1, '');
869             COMMIT;",
870        )?;
871        Ok(())
872    }
873
874    fn open_pointer_connection(&self) -> Result<Connection> {
875        let connection = Connection::open(self.pointer_path())?;
876        configure_connection(&connection)?;
877        Ok(connection)
878    }
879
880    fn compare_and_swap_pointer(
881        &self,
882        generation: &str,
883        base_generation: &str,
884    ) -> Result<PublishOutcome> {
885        let mut connection = self.open_pointer_connection()?;
886        let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
887        let updated = transaction.execute(
888            "UPDATE pointer SET generation = ?1 WHERE generation = ?2",
889            params![generation, base_generation],
890        )?;
891        if updated == 1 {
892            transaction.commit()?;
893            Ok(PublishOutcome::Published)
894        } else {
895            let current_generation: String = transaction.query_row(
896                "SELECT generation FROM pointer WHERE singleton = 1",
897                [],
898                |row| row.get(0),
899            )?;
900            transaction.commit()?;
901            Ok(PublishOutcome::Conflict {
902                current_generation: (!current_generation.is_empty()).then_some(current_generation),
903            })
904        }
905    }
906}
907
908fn validate_filesystem_rel_path(bytes: &[u8]) -> Result<()> {
909    if bytes.is_empty()
910        || bytes.first() == Some(&b'/')
911        || bytes.contains(&0)
912        || bytes
913            .split(|byte| *byte == b'/')
914            .any(|part| part.is_empty() || part == b"." || part == b"..")
915    {
916        return Err(ViewError::InvalidManifest(
917            "rel_path must be a non-empty relative slash-separated byte path without NUL or traversal"
918                .to_string(),
919        ));
920    }
921    Ok(())
922}
923
924fn validate_member(rel_path: &RelPath, entry: &ManifestEntry) -> Result<()> {
925    match entry {
926        ManifestEntry::Synthetic { name, .. } => {
927            if rel_path.as_bytes()
928                != [b'\0']
929                    .into_iter()
930                    .chain(name.as_bytes().iter().copied())
931                    .collect::<Vec<_>>()
932            {
933                return Err(ViewError::InvalidManifest(
934                    "synthetic entries must use the reserved \\0<name> rel_path key".to_string(),
935                ));
936            }
937        }
938        ManifestEntry::Regular { mode, .. } => {
939            if rel_path.is_synthetic() {
940                return Err(ViewError::InvalidManifest(
941                    "only synthetic entries may use a leading NUL rel_path".to_string(),
942                ));
943            }
944            if !matches!(*mode, 0o100644 | 0o100755) {
945                return Err(ViewError::InvalidManifest(
946                    "regular manifest entries must use mode 100644 or 100755".to_string(),
947                ));
948            }
949        }
950        ManifestEntry::Symlink { .. } | ManifestEntry::Gitlink { .. }
951            if rel_path.is_synthetic() =>
952        {
953            return Err(ViewError::InvalidManifest(
954                "only synthetic entries may use a leading NUL rel_path".to_string(),
955            ));
956        }
957        ManifestEntry::Symlink { .. } | ManifestEntry::Gitlink { .. } => {}
958    }
959    Ok(())
960}
961
962fn validate_generation(generation: &str) -> Result<()> {
963    if generation.is_empty()
964        || generation.contains(['/', '\\', '\0'])
965        || generation == "."
966        || generation == ".."
967    {
968        return Err(ViewError::InvalidManifest(
969            "generation must be a non-empty file-name component".to_string(),
970        ));
971    }
972    Ok(())
973}
974
975fn validate_scope_key(project_scope_key: &str) -> Result<()> {
976    if project_scope_key.is_empty()
977        || project_scope_key.contains(['/', '\\', '\0'])
978        || project_scope_key == "."
979        || project_scope_key == ".."
980    {
981        return Err(ViewError::InvalidManifest(
982            "project_scope_key must be a non-empty directory-name component".to_string(),
983        ));
984    }
985    Ok(())
986}
987
988fn configure_connection(connection: &Connection) -> Result<()> {
989    connection.busy_timeout(POINTER_BUSY_TIMEOUT)?;
990    // Two publishers opening the pointer database at once both switch it to
991    // WAL; SQLite skips the busy handler on that lock upgrade when another
992    // connection is mid-switch, so the loser sees SQLITE_BUSY immediately
993    // (seen as `database is locked` 0.36 s into the CAS race test under a
994    // full parallel gate). Wait it out within the busy budget instead.
995    crate::blob_store::retry_while_busy(POINTER_BUSY_TIMEOUT, || {
996        connection.pragma_update(None, "journal_mode", "WAL")
997    })?;
998    connection.pragma_update(None, "synchronous", "NORMAL")?;
999    connection.pragma_update(None, "foreign_keys", "OFF")?;
1000    Ok(())
1001}
1002
1003fn sync_database_wal_without_checkpoint(
1004    path: &Path,
1005    observer: Option<&dyn PublicationObserver>,
1006) -> Result<()> {
1007    if !path.is_file() {
1008        return Err(ViewError::InvalidManifest(format!(
1009            "durability input is not a SQLite file: {}",
1010            path.display()
1011        )));
1012    }
1013    sync_file(path)?;
1014    let wal_path = PathBuf::from(format!("{}-wal", path.display()));
1015    match open_file_for_sync(&wal_path) {
1016        Ok(file) => file.sync_all()?,
1017        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1018        Err(error) => return Err(ViewError::Io(error)),
1019    }
1020    sync_parent(path)?;
1021    observe(observer, PublicationStep::DerivedWalFsynced);
1022    Ok(())
1023}
1024
1025fn checkpoint_and_sync_database(
1026    path: &Path,
1027    observer: Option<&dyn PublicationObserver>,
1028    observe_blob_database: bool,
1029) -> Result<()> {
1030    if !path.is_file() {
1031        return Err(ViewError::InvalidManifest(format!(
1032            "durability input is not a SQLite file: {}",
1033            path.display()
1034        )));
1035    }
1036    let connection = Connection::open(path)?;
1037    configure_connection(&connection)?;
1038    connection.execute_batch("PRAGMA wal_checkpoint(PASSIVE);")?;
1039    if observe_blob_database {
1040        observe(observer, PublicationStep::BlobWalCheckpointed);
1041    }
1042    drop(connection);
1043    sync_file(path)?;
1044    // SQLite deletes the WAL when the last connection to the database closes,
1045    // so between an existence probe and the open the file can legitimately
1046    // vanish when another connection on the same file closes (the checkpoint
1047    // that removal implies already carried its frames into the main file).
1048    // Open directly and treat NotFound as "nothing left to sync" instead of
1049    // probing first.
1050    let wal_path = PathBuf::from(format!("{}-wal", path.display()));
1051    match open_file_for_sync(&wal_path) {
1052        Ok(file) => file.sync_all()?,
1053        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1054        Err(error) => return Err(ViewError::Io(error)),
1055    }
1056    sync_parent(path)?;
1057    if observe_blob_database {
1058        observe(observer, PublicationStep::BlobWalFsync);
1059    }
1060    Ok(())
1061}
1062
1063fn checkpoint_pointer_after_cas(path: &Path) -> Result<()> {
1064    let connection = Connection::open(path)?;
1065    configure_connection(&connection)?;
1066    connection.execute_batch("PRAGMA wal_checkpoint(PASSIVE);")?;
1067    Ok(())
1068}
1069
1070fn write_manifest_once(path: &Path, manifest: &Manifest) -> Result<()> {
1071    let parent = path.parent().ok_or_else(|| {
1072        ViewError::InvalidManifest("manifest path must have a parent directory".to_string())
1073    })?;
1074    let temporary = parent.join(format!(
1075        ".{}.tmp.{}.{}",
1076        path.file_name()
1077            .and_then(|name| name.to_str())
1078            .unwrap_or("manifest"),
1079        std::process::id(),
1080        std::time::SystemTime::now()
1081            .duration_since(std::time::UNIX_EPOCH)
1082            .unwrap_or_default()
1083            .as_nanos()
1084    ));
1085    let write_result = (|| -> Result<()> {
1086        let mut file = OpenOptions::new()
1087            .create_new(true)
1088            .write(true)
1089            .open(&temporary)?;
1090        file.write_all(&manifest.to_json_bytes()?)?;
1091        file.write_all(b"\n")?;
1092        file.sync_all()?;
1093        drop(file);
1094        fs::hard_link(&temporary, path).map_err(|error| {
1095            if error.kind() == std::io::ErrorKind::AlreadyExists {
1096                ViewError::ManifestAlreadyExists(
1097                    path.file_stem()
1098                        .and_then(|name| name.to_str())
1099                        .unwrap_or("unknown")
1100                        .to_string(),
1101                )
1102            } else {
1103                ViewError::Io(error)
1104            }
1105        })?;
1106        // The hard link gives the generation its permanent name without an
1107        // overwrite race; fsync that name before its directory is persisted.
1108        sync_file(path)?;
1109        Ok(())
1110    })();
1111    let _ = fs::remove_file(&temporary);
1112    write_result
1113}
1114
1115fn sync_file_and_parent(path: &Path) -> Result<()> {
1116    sync_file(path)?;
1117    sync_parent(path)
1118}
1119
1120fn sync_file(path: &Path) -> Result<()> {
1121    open_file_for_sync(path)?.sync_all()?;
1122    Ok(())
1123}
1124
1125fn open_file_for_sync(path: &Path) -> std::io::Result<File> {
1126    // A concurrent SQLite checkpoint can briefly retain a Windows handle that
1127    // denies the write-enabled open required by FlushFileBuffers. Retry only
1128    // that open; a persistent denial and every later fsync error still surface.
1129    retry_while_windows_file_busy(FILE_OPEN_RETRY_TIMEOUT, || open_file_for_sync_once(path))
1130}
1131
1132fn open_file_for_sync_once(path: &Path) -> std::io::Result<File> {
1133    #[cfg(windows)]
1134    {
1135        // FlushFileBuffers rejects read-only handles, so Windows must open
1136        // durability inputs for writing even though no bytes are changed.
1137        OpenOptions::new().write(true).open(path)
1138    }
1139    #[cfg(not(windows))]
1140    {
1141        File::open(path)
1142    }
1143}
1144
1145fn retry_while_windows_file_busy<T>(
1146    budget: Duration,
1147    mut operation: impl FnMut() -> std::io::Result<T>,
1148) -> std::io::Result<T> {
1149    let deadline = std::time::Instant::now() + budget;
1150    let mut backoff = Duration::from_millis(1);
1151    loop {
1152        match operation() {
1153            Err(error) if is_windows_file_busy(&error) && std::time::Instant::now() < deadline => {
1154                std::thread::sleep(backoff);
1155                backoff = (backoff * 2).min(Duration::from_millis(50));
1156            }
1157            result => return result,
1158        }
1159    }
1160}
1161
1162fn is_windows_file_busy(error: &std::io::Error) -> bool {
1163    // PermissionDenied is how Rust reports ERROR_ACCESS_DENIED. Keep this arm
1164    // under cfg(test) as well so the retry policy has a portable regression.
1165    #[cfg(any(windows, test))]
1166    if error.kind() == std::io::ErrorKind::PermissionDenied {
1167        return true;
1168    }
1169    #[cfg(windows)]
1170    if matches!(error.raw_os_error(), Some(5 | 32)) {
1171        return true;
1172    }
1173    #[cfg(not(any(windows, test)))]
1174    let _ = error;
1175    false
1176}
1177
1178fn sync_parent(path: &Path) -> Result<()> {
1179    let parent = path.parent().ok_or_else(|| {
1180        ViewError::InvalidManifest("durability input must have a parent directory".to_string())
1181    })?;
1182    sync_directory(parent)
1183}
1184
1185#[cfg(unix)]
1186fn sync_directory(path: &Path) -> Result<()> {
1187    File::open(path)?.sync_all()?;
1188    Ok(())
1189}
1190
1191#[cfg(not(unix))]
1192fn sync_directory(_path: &Path) -> Result<()> {
1193    // Windows does not expose a portable directory handle fsync API. SQLite and
1194    // NTFS provide the durable rename boundary for these files on that platform.
1195    Ok(())
1196}
1197
1198fn observe(observer: Option<&dyn PublicationObserver>, step: PublicationStep) {
1199    if let Some(observer) = observer {
1200        observer.reached(step);
1201    }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207
1208    /// Two publishers checkpoint the same artifact database before publishing.
1209    /// An artifact written in rollback-journal mode is switched to WAL by the
1210    /// first checkpoint connection, and SQLite skips the busy handler on that
1211    /// lock upgrade when another connection is mid-switch (the same first-open
1212    /// race the blob store pins), so the loser saw `database is locked` 0.36 s
1213    /// into the CAS race test under a full parallel gate. Eight racing
1214    /// checkpointers on a fresh rollback-journal file redden without the retry
1215    /// in `configure_connection` within 150 rounds.
1216    #[test]
1217    fn concurrent_artifact_checkpoints_never_see_busy() {
1218        let mut failures = Vec::new();
1219        for round in 0..150 {
1220            let dir = tempfile::tempdir().expect("tempdir");
1221            let artifact = dir.path().join("artifact.sqlite");
1222            Connection::open(&artifact)
1223                .expect("artifact")
1224                .execute_batch("CREATE TABLE t (x INTEGER);")
1225                .expect("schema");
1226            let handles: Vec<_> = (0..8)
1227                .map(|_| {
1228                    let artifact = artifact.clone();
1229                    std::thread::spawn(move || checkpoint_and_sync_database(&artifact, None, false))
1230                })
1231                .collect();
1232            for handle in handles {
1233                if let Err(error) = handle.join().expect("opener thread") {
1234                    failures.push(format!("round {round}: {error}"));
1235                }
1236            }
1237        }
1238        assert!(
1239            failures.is_empty(),
1240            "concurrent artifact checkpoints must wait out the WAL switch: {failures:?}"
1241        );
1242    }
1243
1244    #[test]
1245    fn sync_file_flushes_an_existing_artifact() {
1246        let dir = tempfile::tempdir().expect("tempdir");
1247        let artifact = dir.path().join("artifact.bin");
1248        fs::write(&artifact, b"durable").expect("artifact");
1249
1250        sync_file(&artifact).expect("flush pre-existing artifact");
1251    }
1252
1253    #[test]
1254    fn windows_file_busy_retry_recovers_from_injected_permission_denied() {
1255        let mut attempts = 0;
1256        let result = retry_while_windows_file_busy(Duration::from_millis(100), || {
1257            attempts += 1;
1258            if attempts == 1 {
1259                Err(std::io::Error::new(
1260                    std::io::ErrorKind::PermissionDenied,
1261                    "injected Windows sharing failure",
1262                ))
1263            } else {
1264                Ok("opened")
1265            }
1266        });
1267
1268        assert_eq!(result.unwrap(), "opened");
1269        assert_eq!(attempts, 2);
1270    }
1271
1272    #[test]
1273    fn byte_string_uses_base64_only_when_utf8_cannot_represent_the_path() {
1274        assert_eq!(
1275            serde_json::to_string(&ByteString::from(b"src/lib.rs".as_slice())).unwrap(),
1276            "\"src/lib.rs\""
1277        );
1278        assert_eq!(
1279            serde_json::to_string(&ByteString::from(b"bad-\xff".as_slice())).unwrap(),
1280            "{\"b64\":\"YmFkLf8=\"}"
1281        );
1282    }
1283
1284    #[test]
1285    fn synthetic_paths_sort_before_filesystem_paths() {
1286        let synthetic = RelPath::synthetic("global-gitignore").unwrap();
1287        let regular = RelPath::new(b"src/main.rs".to_vec()).unwrap();
1288        assert!(synthetic < regular);
1289    }
1290}