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