Skip to main content

aft/alias/
mod.rs

1//! Proven Git-object aliases and byte-exact manifest path identities.
2//!
3//! Git object IDs hash a `blob <len>\0` header as well as file bytes, while
4//! content-addressed artifacts use BLAKE3 of the file bytes.  This module keeps
5//! that distinction explicit: aliases are accepted only after recomputing the
6//! Git blob ID from the exact bytes.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::fs;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::{Command, Output, Stdio};
14use std::time::{Duration, Instant, SystemTime};
15
16use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
17use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
18use sha1::Sha1;
19use sha2::Digest;
20
21/// The manifest format that stores paths as exact, slash-separated bytes.
22pub const PATH_IDENTITY_VERSION: u32 = 1;
23
24const GIT_METADATA_TIMEOUT: Duration = Duration::from_secs(30);
25
26// Thread-local because the navigation calls under test run on the test
27// thread; a process-wide counter would count sibling tests' publications.
28#[cfg(test)]
29thread_local! {
30    static HEAD_TREE_ENTRY_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
31}
32
33#[cfg(test)]
34pub(crate) fn reset_head_tree_entry_calls_for_test() {
35    HEAD_TREE_ENTRY_CALLS.with(|calls| calls.set(0));
36}
37
38#[cfg(test)]
39pub(crate) fn head_tree_entry_calls_for_test() -> usize {
40    HEAD_TREE_ENTRY_CALLS.with(std::cell::Cell::get)
41}
42
43const ALIAS_SCHEMA: &str = r#"
44CREATE TABLE IF NOT EXISTS oid_aliases (
45    git_oid BLOB NOT NULL PRIMARY KEY CHECK(length(git_oid) = 20),
46    blake3 BLOB NOT NULL CHECK(length(blake3) = 32)
47) WITHOUT ROWID;
48"#;
49
50const MANIFEST_SCHEMA: &str = r#"
51CREATE TABLE IF NOT EXISTS manifest_metadata (
52    singleton INTEGER NOT NULL PRIMARY KEY CHECK(singleton = 1),
53    path_identity_version INTEGER NOT NULL
54) WITHOUT ROWID;
55CREATE TABLE IF NOT EXISTS manifest_entries (
56    rel_path BLOB NOT NULL PRIMARY KEY,
57    entry_json TEXT NOT NULL
58) WITHOUT ROWID;
59"#;
60
61/// Errors raised while proving aliases or preserving path identity.
62#[derive(Debug)]
63pub enum AliasError {
64    Io(std::io::Error),
65    Sqlite(rusqlite::Error),
66    Git {
67        command: &'static str,
68        stderr: String,
69    },
70    InvalidArtifactKey(String),
71    InvalidGitOid(String),
72    InvalidRelativePath(String),
73    InvalidManifestEntry(String),
74    DuplicateManifestPath(Vec<u8>),
75    CorruptAliasDigest,
76    ConflictingAlias(GitOid),
77}
78
79impl fmt::Display for AliasError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Io(error) => write!(f, "alias/path-identity I/O error: {error}"),
83            Self::Sqlite(error) => write!(f, "alias/path-identity SQLite error: {error}"),
84            Self::Git { command, stderr } => write!(f, "git {command} failed: {stderr}"),
85            Self::InvalidArtifactKey(key) => write!(f, "invalid artifact key `{key}`"),
86            Self::InvalidGitOid(oid) => write!(f, "invalid SHA-1 Git object ID `{oid}`"),
87            Self::InvalidRelativePath(reason) => {
88                write!(f, "invalid manifest relative path: {reason}")
89            }
90            Self::InvalidManifestEntry(reason) => write!(f, "invalid manifest entry: {reason}"),
91            Self::DuplicateManifestPath(path) => {
92                write!(f, "duplicate manifest path `{}`", path_display(path))
93            }
94            Self::CorruptAliasDigest => {
95                f.write_str("stored alias has an invalid BLAKE3 digest length")
96            }
97            Self::ConflictingAlias(oid) => write!(
98                f,
99                "Git object ID {oid} is already aliased to different BLAKE3 bytes"
100            ),
101        }
102    }
103}
104
105impl std::error::Error for AliasError {
106    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
107        match self {
108            Self::Io(error) => Some(error),
109            Self::Sqlite(error) => Some(error),
110            Self::Git { .. }
111            | Self::InvalidArtifactKey(_)
112            | Self::InvalidGitOid(_)
113            | Self::InvalidRelativePath(_)
114            | Self::InvalidManifestEntry(_)
115            | Self::DuplicateManifestPath(_)
116            | Self::CorruptAliasDigest
117            | Self::ConflictingAlias(_) => None,
118        }
119    }
120}
121
122impl From<std::io::Error> for AliasError {
123    fn from(error: std::io::Error) -> Self {
124        Self::Io(error)
125    }
126}
127
128impl From<rusqlite::Error> for AliasError {
129    fn from(error: rusqlite::Error) -> Self {
130        Self::Sqlite(error)
131    }
132}
133
134/// A SHA-1 Git object ID represented as its 20 raw bytes, not text.
135#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
136pub struct GitOid([u8; 20]);
137
138impl GitOid {
139    /// Parses Git's lower- or upper-case, 40-character SHA-1 object ID form.
140    pub fn from_hex(value: &str) -> Result<Self, AliasError> {
141        if value.len() != 40 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) {
142            return Err(AliasError::InvalidGitOid(value.to_owned()));
143        }
144
145        let mut bytes = [0_u8; 20];
146        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
147            let high =
148                hex_nibble(pair[0]).ok_or_else(|| AliasError::InvalidGitOid(value.to_owned()))?;
149            let low =
150                hex_nibble(pair[1]).ok_or_else(|| AliasError::InvalidGitOid(value.to_owned()))?;
151            bytes[index] = high << 4 | low;
152        }
153        Ok(Self(bytes))
154    }
155
156    /// Creates an object ID from the raw bytes used by the alias SQLite key.
157    pub fn from_bytes(value: &[u8]) -> Result<Self, AliasError> {
158        value
159            .try_into()
160            .map(Self)
161            .map_err(|_| AliasError::InvalidGitOid(hex(value)))
162    }
163
164    /// Raw bytes suitable for a SQLite BLOB key.
165    pub const fn as_bytes(&self) -> &[u8; 20] {
166        &self.0
167    }
168
169    /// Lower-case hexadecimal form used by Git command output and JSON manifests.
170    pub fn to_hex(&self) -> String {
171        hex(&self.0)
172    }
173}
174
175impl fmt::Display for GitOid {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(&self.to_hex())
178    }
179}
180
181/// Git tree modes that determine whether a path can be aliased.
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub enum GitMode {
184    Regular { executable: bool },
185    Symlink,
186    Gitlink,
187    Other(Vec<u8>),
188}
189
190impl GitMode {
191    /// Parses the mode field emitted by `git ls-tree` without converting paths or
192    /// object IDs through a lossy string representation.
193    pub fn from_git_mode(value: &[u8]) -> Self {
194        match value {
195            b"100644" => Self::Regular { executable: false },
196            b"100755" => Self::Regular { executable: true },
197            b"120000" => Self::Symlink,
198            b"160000" => Self::Gitlink,
199            _ => Self::Other(value.to_vec()),
200        }
201    }
202
203    /// Returns true only for Git's regular-file modes: `100644` and `100755`.
204    pub const fn is_regular(&self) -> bool {
205        matches!(self, Self::Regular { .. })
206    }
207
208    /// The canonical Git tree mode bytes.
209    pub fn as_bytes(&self) -> &[u8] {
210        match self {
211            Self::Regular { executable: false } => b"100644",
212            Self::Regular { executable: true } => b"100755",
213            Self::Symlink => b"120000",
214            Self::Gitlink => b"160000",
215            Self::Other(value) => value,
216        }
217    }
218}
219
220/// A Git-tracked path with the metadata used to decide whether it can be aliased.
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct TrackedPath {
223    /// Exact Git path bytes. They always use `/` separators.
224    pub rel_path: Vec<u8>,
225    pub mode: GitMode,
226    pub git_oid: GitOid,
227    /// A path with a Git clean/smudge filter, including Git LFS, is never aliased.
228    pub filtered: bool,
229    /// Only paths present in the previous generation belong in the zero-read report denominator.
230    pub present_in_previous_generation: bool,
231    /// `false` is accepted for report inputs so untracked files cannot accidentally
232    /// become eligible when callers combine Git and watcher path lists.
233    pub tracked: bool,
234}
235
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub struct GitHeadMetadata {
238    pub head_path: PathBuf,
239    pub head_mtime: Option<SystemTime>,
240    pub resolved_ref_path: Option<PathBuf>,
241    pub resolved_ref_mtime: Option<SystemTime>,
242}
243
244impl GitHeadMetadata {
245    pub fn matches_path(&self, path: &Path) -> bool {
246        same_file_path(path, &self.head_path)
247            || self
248                .resolved_ref_path
249                .as_deref()
250                .is_some_and(|resolved| same_file_path(path, resolved))
251    }
252
253    pub fn watch_paths(&self) -> impl Iterator<Item = &Path> {
254        std::iter::once(self.head_path.as_path()).chain(self.resolved_ref_path.as_deref())
255    }
256}
257
258impl TrackedPath {
259    pub fn new(rel_path: Vec<u8>, mode: GitMode, git_oid: GitOid) -> Result<Self, AliasError> {
260        validate_rel_path(&rel_path)?;
261        Ok(Self {
262            rel_path,
263            mode,
264            git_oid,
265            filtered: false,
266            present_in_previous_generation: false,
267            tracked: true,
268        })
269    }
270
271    pub fn with_filter_status(mut self, filtered: bool) -> Self {
272        self.filtered = filtered;
273        self
274    }
275
276    pub fn with_previous_generation(mut self, present: bool) -> Self {
277        self.present_in_previous_generation = present;
278        self
279    }
280
281    /// Returns true when the mode and filter policy permit a Git-to-BLAKE3 alias.
282    pub fn is_alias_eligible(&self) -> bool {
283        self.tracked && self.mode.is_regular() && !self.filtered
284    }
285}
286
287/// Why a candidate was deliberately not entered into the alias table.
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
289pub enum AliasSkip {
290    Untracked,
291    Symlink,
292    Gitlink,
293    NonRegular,
294    Filtered,
295    LfsPointer,
296    GitOidMismatch,
297}
298
299/// The immutable result of attempting to seed one alias.
300#[derive(Clone, Copy, Debug, Eq, PartialEq)]
301pub enum AliasWrite {
302    Inserted([u8; 32]),
303    Reused([u8; 32]),
304    Skipped(AliasSkip),
305}
306
307/// SQLite storage for proven `(git_oid -> blake3(bytes))` aliases.
308pub struct AliasStore {
309    path: PathBuf,
310    connection: Connection,
311}
312
313impl AliasStore {
314    /// Opens `<storage>/blobs/<artifact_key>/oid-alias.sqlite`.
315    pub fn open(storage: &Path, artifact_key: &str) -> Result<Self, AliasError> {
316        validate_artifact_key(artifact_key)?;
317        let path = storage
318            .join("blobs")
319            .join(artifact_key)
320            .join("oid-alias.sqlite");
321        if let Some(parent) = path.parent() {
322            fs::create_dir_all(parent)?;
323        }
324
325        let connection = Connection::open(&path)?;
326        connection.busy_timeout(std::time::Duration::from_millis(5_000))?;
327        connection.execute_batch(
328            "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;",
329        )?;
330        connection.execute_batch(ALIAS_SCHEMA)?;
331        Ok(Self { path, connection })
332    }
333
334    pub fn path(&self) -> &Path {
335        &self.path
336    }
337
338    /// Resolves a previously proven alias without reading the checkout file.
339    pub fn resolve(&self, git_oid: GitOid) -> Result<Option<[u8; 32]>, AliasError> {
340        let digest = self
341            .connection
342            .query_row(
343                "SELECT blake3 FROM oid_aliases WHERE git_oid = ?1",
344                params![git_oid.as_bytes().as_slice()],
345                |row| row.get::<_, Vec<u8>>(0),
346            )
347            .optional()?;
348        digest
349            .map(|digest| {
350                digest
351                    .try_into()
352                    .map_err(|_| AliasError::CorruptAliasDigest)
353            })
354            .transpose()
355    }
356
357    /// Writes an alias only after independently proving the Git blob hash.
358    ///
359    /// The working-tree caller supplies bytes it already read for indexing. This
360    /// method never substitutes a text conversion for those bytes, so Git's
361    /// header length and BLAKE3 digest describe the same byte sequence.
362    pub fn seed_proven_alias(
363        &mut self,
364        path: &TrackedPath,
365        bytes: &[u8],
366    ) -> Result<AliasWrite, AliasError> {
367        validate_rel_path(&path.rel_path)?;
368        let eligibility = if !path.tracked {
369            Some(AliasSkip::Untracked)
370        } else if path.filtered {
371            Some(AliasSkip::Filtered)
372        } else {
373            match path.mode {
374                GitMode::Regular { .. } => None,
375                GitMode::Symlink => Some(AliasSkip::Symlink),
376                GitMode::Gitlink => Some(AliasSkip::Gitlink),
377                GitMode::Other(_) => Some(AliasSkip::NonRegular),
378            }
379        };
380        if let Some(skip) = eligibility {
381            return Ok(AliasWrite::Skipped(skip));
382        }
383        if is_lfs_pointer(bytes) {
384            return Ok(AliasWrite::Skipped(AliasSkip::LfsPointer));
385        }
386        if git_blob_oid(bytes) != path.git_oid {
387            return Ok(AliasWrite::Skipped(AliasSkip::GitOidMismatch));
388        }
389
390        let digest = *blake3::hash(bytes).as_bytes();
391        let tx = self
392            .connection
393            .transaction_with_behavior(TransactionBehavior::Immediate)?;
394        let existing = tx
395            .query_row(
396                "SELECT blake3 FROM oid_aliases WHERE git_oid = ?1",
397                params![path.git_oid.as_bytes().as_slice()],
398                |row| row.get::<_, Vec<u8>>(0),
399            )
400            .optional()?;
401        let outcome = match existing {
402            Some(existing) if existing.as_slice() == digest => AliasWrite::Reused(digest),
403            Some(_) => return Err(AliasError::ConflictingAlias(path.git_oid)),
404            None => {
405                tx.execute(
406                    "INSERT INTO oid_aliases (git_oid, blake3) VALUES (?1, ?2)",
407                    params![path.git_oid.as_bytes().as_slice(), digest.as_slice()],
408                )?;
409                AliasWrite::Inserted(digest)
410            }
411        };
412        tx.commit()?;
413        Ok(outcome)
414    }
415
416    pub fn alias_count(&self) -> Result<usize, AliasError> {
417        self.connection
418            .query_row("SELECT COUNT(*) FROM oid_aliases", [], |row| row.get(0))
419            .map_err(Into::into)
420    }
421
422    /// Measures zero-read checkout resolution. Only tracked regular, unfiltered
423    /// paths present in the previous generation are eligible for the denominator.
424    pub fn zero_read_checkout_report(
425        &self,
426        paths: impl IntoIterator<Item = impl std::borrow::Borrow<TrackedPath>>,
427    ) -> Result<ZeroReadCheckoutReport, AliasError> {
428        let mut report = ZeroReadCheckoutReport::default();
429        for path in paths {
430            let path = path.borrow();
431            let exclusion = if !path.tracked {
432                Some(ExcludedPathClass::Untracked)
433            } else if path.filtered {
434                Some(ExcludedPathClass::Filtered)
435            } else {
436                match path.mode {
437                    GitMode::Regular { .. } if path.present_in_previous_generation => None,
438                    GitMode::Regular { .. } => Some(ExcludedPathClass::NotPreviouslyIndexed),
439                    GitMode::Symlink => Some(ExcludedPathClass::Symlink),
440                    GitMode::Gitlink => Some(ExcludedPathClass::Gitlink),
441                    GitMode::Other(_) => Some(ExcludedPathClass::NonRegular),
442                }
443            };
444            if let Some(exclusion) = exclusion {
445                report.excluded.record(exclusion);
446                continue;
447            }
448
449            report.denominator += 1;
450            if self.resolve(path.git_oid)?.is_some() {
451                report.numerator += 1;
452            }
453        }
454        Ok(report)
455    }
456
457    /// Reads only Git metadata for `HEAD`, then applies the zero-read report to
458    /// paths named by a previous manifest. File contents are never opened here.
459    pub fn report_head_checkout(
460        &self,
461        repo_root: &Path,
462        previous_manifest_paths: &BTreeSet<Vec<u8>>,
463    ) -> Result<ZeroReadCheckoutReport, AliasError> {
464        let mut paths = head_tree_entries(repo_root)?;
465        for path in &mut paths {
466            path.present_in_previous_generation = previous_manifest_paths.contains(&path.rel_path);
467        }
468        self.zero_read_checkout_report(&paths)
469    }
470}
471
472/// Excluded path totals printed with a zero-read checkout report.
473#[derive(Clone, Debug, Default, Eq, PartialEq)]
474pub struct ExcludedPathClasses {
475    pub untracked: usize,
476    pub symlink: usize,
477    pub gitlink: usize,
478    pub filtered: usize,
479    pub non_regular: usize,
480    pub not_previously_indexed: usize,
481}
482
483impl ExcludedPathClasses {
484    fn record(&mut self, class: ExcludedPathClass) {
485        match class {
486            ExcludedPathClass::Untracked => self.untracked += 1,
487            ExcludedPathClass::Symlink => self.symlink += 1,
488            ExcludedPathClass::Gitlink => self.gitlink += 1,
489            ExcludedPathClass::Filtered => self.filtered += 1,
490            ExcludedPathClass::NonRegular => self.non_regular += 1,
491            ExcludedPathClass::NotPreviouslyIndexed => self.not_previously_indexed += 1,
492        }
493    }
494}
495
496#[derive(Clone, Copy)]
497enum ExcludedPathClass {
498    Untracked,
499    Symlink,
500    Gitlink,
501    Filtered,
502    NonRegular,
503    NotPreviouslyIndexed,
504}
505
506/// The numerator, denominator, and every excluded path class for zero-read checkout reporting.
507#[derive(Clone, Debug, Default, Eq, PartialEq)]
508pub struct ZeroReadCheckoutReport {
509    pub numerator: usize,
510    pub denominator: usize,
511    pub excluded: ExcludedPathClasses,
512}
513
514impl ZeroReadCheckoutReport {
515    /// Uses integer arithmetic so the 95% acceptance boundary is deterministic.
516    pub fn meets_95_percent(&self) -> bool {
517        self.denominator != 0 && (self.numerator as u128) * 100 >= (self.denominator as u128) * 95
518    }
519}
520
521impl fmt::Display for ZeroReadCheckoutReport {
522    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523        write!(
524            f,
525            "zero-read checkout: numerator={} denominator={} excluded={{untracked={}, symlink={}, gitlink={}, filtered={}, non_regular={}, not_previously_indexed={}}}",
526            self.numerator,
527            self.denominator,
528            self.excluded.untracked,
529            self.excluded.symlink,
530            self.excluded.gitlink,
531            self.excluded.filtered,
532            self.excluded.non_regular,
533            self.excluded.not_previously_indexed,
534        )
535    }
536}
537
538/// A manifest's two optional plane keys for a regular path.
539#[derive(Clone, Debug, Default, Eq, PartialEq)]
540pub struct PlaneKeys {
541    pub semantic: Option<[u8; 32]>,
542    pub callgraph: Option<[u8; 32]>,
543}
544
545/// The tagged manifest entry schema. Paths live beside entries as raw BLOBs.
546#[derive(Clone, Debug, Eq, PartialEq)]
547pub enum ManifestEntry {
548    Regular {
549        mode: GitMode,
550        planes: PlaneKeys,
551        resolution_input: bool,
552    },
553    Symlink {
554        target_bytes: Vec<u8>,
555    },
556    Gitlink {
557        oid: GitOid,
558    },
559    Synthetic {
560        name: String,
561        callgraph: [u8; 32],
562    },
563}
564
565impl ManifestEntry {
566    pub fn regular(mode: GitMode, planes: PlaneKeys, resolution_input: bool) -> Self {
567        Self::Regular {
568            mode,
569            planes,
570            resolution_input,
571        }
572    }
573}
574
575/// One byte-exact manifest path and its tagged entry.
576#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct ManifestRecord {
578    pub rel_path: Vec<u8>,
579    pub entry: ManifestEntry,
580}
581
582/// A single view manifest with a fixed path identity encoding version.
583#[derive(Clone, Debug, Eq, PartialEq)]
584pub struct Manifest {
585    pub path_identity_version: u32,
586    pub entries: Vec<ManifestRecord>,
587}
588
589impl Manifest {
590    /// Validates entries and orders them by raw path bytes, not platform strings.
591    pub fn new(mut entries: Vec<ManifestRecord>) -> Result<Self, AliasError> {
592        for record in &entries {
593            validate_manifest_record(record)?;
594        }
595        entries.sort_by(|left, right| left.rel_path.cmp(&right.rel_path));
596        for pair in entries.windows(2) {
597            if pair[0].rel_path == pair[1].rel_path {
598                return Err(AliasError::DuplicateManifestPath(pair[0].rel_path.clone()));
599            }
600        }
601        Ok(Self {
602            path_identity_version: PATH_IDENTITY_VERSION,
603            entries,
604        })
605    }
606
607    /// Encodes the canonical JSON manifest. UTF-8 paths are strings; non-UTF-8
608    /// paths are objects of the exact required shape: `{"b64":"..."}`.
609    pub fn to_json_value(&self) -> serde_json::Value {
610        let entries = self
611            .entries
612            .iter()
613            .map(|record| {
614                let mut object = entry_json_object(&record.entry);
615                object.insert("rel_path".to_string(), json_bytes(&record.rel_path));
616                serde_json::Value::Object(object)
617            })
618            .collect::<Vec<_>>();
619        serde_json::json!({
620            "path_identity_version": self.path_identity_version,
621            "entries": entries,
622        })
623    }
624
625    pub fn to_json_string(&self) -> String {
626        self.to_json_value().to_string()
627    }
628}
629
630/// SQLite representation for raw manifest paths. `rel_path` is deliberately a
631/// BLOB column so SQLite never applies text collation or Unicode normalization.
632pub struct ManifestSqliteStore {
633    connection: Connection,
634}
635
636impl ManifestSqliteStore {
637    pub fn open(path: &Path) -> Result<Self, AliasError> {
638        if let Some(parent) = path.parent() {
639            fs::create_dir_all(parent)?;
640        }
641        let connection = Connection::open(path)?;
642        connection.execute_batch(MANIFEST_SCHEMA)?;
643        Ok(Self { connection })
644    }
645
646    /// Replaces the store's one manifest snapshot atomically.
647    pub fn write(&mut self, manifest: &Manifest) -> Result<(), AliasError> {
648        if manifest.path_identity_version != PATH_IDENTITY_VERSION {
649            return Err(AliasError::InvalidManifestEntry(format!(
650                "path_identity_version must be {PATH_IDENTITY_VERSION}"
651            )));
652        }
653        let tx = self
654            .connection
655            .transaction_with_behavior(TransactionBehavior::Immediate)?;
656        tx.execute("DELETE FROM manifest_entries", [])?;
657        tx.execute(
658            "INSERT INTO manifest_metadata (singleton, path_identity_version) VALUES (1, ?1)
659             ON CONFLICT(singleton) DO UPDATE SET path_identity_version = excluded.path_identity_version",
660            params![i64::from(manifest.path_identity_version)],
661        )?;
662        for record in &manifest.entries {
663            let entry_json =
664                serde_json::Value::Object(entry_json_object(&record.entry)).to_string();
665            tx.execute(
666                "INSERT INTO manifest_entries (rel_path, entry_json) VALUES (?1, ?2)",
667                params![record.rel_path, entry_json],
668            )?;
669        }
670        tx.commit()?;
671        Ok(())
672    }
673
674    pub fn path_identity_version(&self) -> Result<Option<u32>, AliasError> {
675        self.connection
676            .query_row(
677                "SELECT path_identity_version FROM manifest_metadata WHERE singleton = 1",
678                [],
679                |row| row.get::<_, u32>(0),
680            )
681            .optional()
682            .map_err(Into::into)
683    }
684
685    /// Returns raw BLOB paths in SQLite's bytewise primary-key order.
686    pub fn paths(&self) -> Result<Vec<Vec<u8>>, AliasError> {
687        let mut statement = self
688            .connection
689            .prepare("SELECT rel_path FROM manifest_entries ORDER BY rel_path")?;
690        let rows = statement.query_map([], |row| row.get(0))?;
691        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
692    }
693}
694
695/// Computes Git's SHA-1 blob object ID from exact file bytes.
696pub fn git_blob_oid(bytes: &[u8]) -> GitOid {
697    let mut hasher = Sha1::new();
698    hasher.update(b"blob ");
699    hasher.update(bytes.len().to_string().as_bytes());
700    hasher.update([0]);
701    hasher.update(bytes);
702    let digest = hasher.finalize();
703    let mut oid = [0_u8; 20];
704    oid.copy_from_slice(&digest);
705    GitOid(oid)
706}
707
708/// Returns whether `bytes` are an LFS pointer. This conservative secondary
709/// check protects aliases even if a caller failed to propagate `filter=lfs`.
710pub fn is_lfs_pointer(bytes: &[u8]) -> bool {
711    bytes.starts_with(b"version https://git-lfs.github.com/spec/v1\n")
712}
713
714pub fn capture_git_head_metadata(
715    repo_root: &Path,
716    git_common_dir: Option<&Path>,
717) -> Result<GitHeadMetadata, AliasError> {
718    let dot_git = repo_root.join(".git");
719    let git_dir = if dot_git.is_dir() {
720        canonical_or_original(dot_git)
721    } else {
722        let marker = fs::read_to_string(&dot_git)?;
723        let relative = marker
724            .trim()
725            .strip_prefix("gitdir:")
726            .map(str::trim)
727            .ok_or_else(|| {
728                std::io::Error::new(
729                    std::io::ErrorKind::InvalidData,
730                    format!("invalid gitdir marker at {}", dot_git.display()),
731                )
732            })?;
733        let path = PathBuf::from(relative);
734        canonical_or_original(if path.is_absolute() {
735            path
736        } else {
737            repo_root.join(path)
738        })
739    };
740    let head_path = canonical_or_original(git_dir.join("HEAD"));
741    let head = fs::read_to_string(&head_path)?;
742    let resolved_ref_path = head
743        .trim()
744        .strip_prefix("ref:")
745        .map(str::trim)
746        .filter(|reference| !reference.is_empty())
747        .map(|reference| canonical_or_original(git_common_dir.unwrap_or(&git_dir).join(reference)));
748    Ok(GitHeadMetadata {
749        head_mtime: modified_time(&head_path),
750        resolved_ref_mtime: resolved_ref_path.as_deref().and_then(modified_time),
751        head_path,
752        resolved_ref_path,
753    })
754}
755
756fn canonical_or_original(path: PathBuf) -> PathBuf {
757    fs::canonicalize(&path).unwrap_or(path)
758}
759
760fn modified_time(path: &Path) -> Option<SystemTime> {
761    fs::metadata(path)
762        .and_then(|metadata| metadata.modified())
763        .ok()
764}
765
766fn same_file_path(path: &Path, target: &Path) -> bool {
767    path == target
768        || fs::canonicalize(target)
769            .map(|target| path == target)
770            .unwrap_or(false)
771}
772
773/// Lists `HEAD` paths from Git metadata without opening working-tree files.
774pub fn head_tree_entries(repo_root: &Path) -> Result<Vec<TrackedPath>, AliasError> {
775    #[cfg(test)]
776    HEAD_TREE_ENTRY_CALLS.with(|calls| calls.set(calls.get() + 1));
777    let output = Command::new("git")
778        .arg("-C")
779        .arg(repo_root)
780        .args(["ls-tree", "-r", "-z", "HEAD"])
781        .output()?;
782    if !output.status.success() {
783        return Err(AliasError::Git {
784            command: "ls-tree -r -z HEAD",
785            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
786        });
787    }
788
789    let mut paths = parse_ls_tree_output(&output.stdout)?;
790    let filters = git_filter_attributes(repo_root, &paths)?;
791    for path in &mut paths {
792        path.filtered = filters.get(&path.rel_path).copied().unwrap_or(false);
793    }
794    Ok(paths)
795}
796
797fn parse_ls_tree_output(output: &[u8]) -> Result<Vec<TrackedPath>, AliasError> {
798    output
799        .split(|byte| *byte == 0)
800        .filter(|record| !record.is_empty())
801        .map(|record| {
802            let separator = record
803                .iter()
804                .position(|byte| *byte == b'\t')
805                .ok_or_else(|| {
806                    AliasError::InvalidManifestEntry("malformed git ls-tree record".to_owned())
807                })?;
808            let (header, rel_path) = (&record[..separator], &record[separator + 1..]);
809            let mut fields = header.split(|byte| *byte == b' ');
810            let mode = fields.next().ok_or_else(|| {
811                AliasError::InvalidManifestEntry("missing git tree mode".to_owned())
812            })?;
813            let _object_type = fields.next().ok_or_else(|| {
814                AliasError::InvalidManifestEntry("missing git tree object type".to_owned())
815            })?;
816            let oid = fields.next().ok_or_else(|| {
817                AliasError::InvalidManifestEntry("missing git tree object ID".to_owned())
818            })?;
819            if fields.next().is_some() {
820                return Err(AliasError::InvalidManifestEntry(
821                    "malformed git tree header".to_owned(),
822                ));
823            }
824            let oid = std::str::from_utf8(oid)
825                .map_err(|_| AliasError::InvalidGitOid(hex(oid)))
826                .and_then(GitOid::from_hex)?;
827            TrackedPath::new(rel_path.to_vec(), GitMode::from_git_mode(mode), oid)
828        })
829        .collect()
830}
831
832fn run_command_with_input(
833    command: &mut Command,
834    input: Vec<u8>,
835    timeout: Duration,
836) -> std::io::Result<Output> {
837    let mut child = command
838        .stdin(Stdio::piped())
839        .stdout(Stdio::piped())
840        .stderr(Stdio::piped())
841        .spawn()?;
842    let mut stdin = child.stdin.take().ok_or_else(|| {
843        std::io::Error::new(std::io::ErrorKind::BrokenPipe, "child stdin was not piped")
844    })?;
845    let mut stdout = child.stdout.take().ok_or_else(|| {
846        std::io::Error::new(std::io::ErrorKind::BrokenPipe, "child stdout was not piped")
847    })?;
848    let mut stderr = child.stderr.take().ok_or_else(|| {
849        std::io::Error::new(std::io::ErrorKind::BrokenPipe, "child stderr was not piped")
850    })?;
851
852    // Git may produce one output record for every input path. Pump all three
853    // pipes concurrently so no pipe can fill while the parent waits on another.
854    let input_thread = std::thread::spawn(move || stdin.write_all(&input));
855    let stdout_thread = std::thread::spawn(move || {
856        let mut bytes = Vec::new();
857        stdout.read_to_end(&mut bytes).map(|_| bytes)
858    });
859    let stderr_thread = std::thread::spawn(move || {
860        let mut bytes = Vec::new();
861        stderr.read_to_end(&mut bytes).map(|_| bytes)
862    });
863
864    let deadline = Instant::now() + timeout;
865    let status = loop {
866        if let Some(status) = child.try_wait()? {
867            break status;
868        }
869        if Instant::now() >= deadline {
870            let _ = child.kill();
871            let _ = child.wait();
872            return Err(std::io::Error::new(
873                std::io::ErrorKind::TimedOut,
874                format!("child process exceeded {}s deadline", timeout.as_secs()),
875            ));
876        }
877        std::thread::sleep(Duration::from_millis(10));
878    };
879
880    let input_result = input_thread
881        .join()
882        .map_err(|_| std::io::Error::other("child stdin pump panicked"))?;
883    let stdout = stdout_thread
884        .join()
885        .map_err(|_| std::io::Error::other("child stdout pump panicked"))??;
886    let stderr = stderr_thread
887        .join()
888        .map_err(|_| std::io::Error::other("child stderr pump panicked"))??;
889    input_result?;
890    Ok(Output {
891        status,
892        stdout,
893        stderr,
894    })
895}
896
897fn git_filter_attributes(
898    repo_root: &Path,
899    paths: &[TrackedPath],
900) -> Result<BTreeMap<Vec<u8>, bool>, AliasError> {
901    if paths.is_empty() {
902        return Ok(BTreeMap::new());
903    }
904
905    let mut input = Vec::new();
906    for path in paths {
907        input.extend_from_slice(&path.rel_path);
908        input.push(0);
909    }
910    let started_at = Instant::now();
911    let output = run_command_with_input(
912        Command::new("git").arg("-C").arg(repo_root).args([
913            "check-attr",
914            "--cached",
915            "-z",
916            "--stdin",
917            "filter",
918        ]),
919        input,
920        GIT_METADATA_TIMEOUT,
921    )?;
922    // Info level: the daemon does not emit debug, and this is the only line an
923    // operator can read to see that a large root's attribute pass finished
924    // (the write-then-read form of this call once blocked executor workers).
925    crate::slog_info!(
926        "git check-attr completed for {} path(s) in {}ms",
927        paths.len(),
928        started_at.elapsed().as_millis()
929    );
930    if !output.status.success() {
931        return Err(AliasError::Git {
932            command: "check-attr --cached -z --stdin filter",
933            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
934        });
935    }
936
937    let fields = output
938        .stdout
939        .split(|byte| *byte == 0)
940        .filter(|field| !field.is_empty())
941        .collect::<Vec<_>>();
942    if fields.len() % 3 != 0 {
943        return Err(AliasError::InvalidManifestEntry(
944            "malformed git check-attr output".to_owned(),
945        ));
946    }
947
948    let mut filtered = BTreeMap::new();
949    for fields in fields.chunks_exact(3) {
950        let path = fields[0].to_vec();
951        let attribute = fields[1];
952        let value = fields[2];
953        if attribute != b"filter" {
954            return Err(AliasError::InvalidManifestEntry(
955                "unexpected git check-attr attribute".to_owned(),
956            ));
957        }
958        filtered.insert(
959            path,
960            !matches!(value, b"unspecified" | b"unset" | b"set" | b""),
961        );
962    }
963    Ok(filtered)
964}
965
966fn validate_artifact_key(artifact_key: &str) -> Result<(), AliasError> {
967    if artifact_key.is_empty()
968        || artifact_key == "."
969        || artifact_key == ".."
970        || artifact_key.contains(['/', '\\', '\0'])
971    {
972        return Err(AliasError::InvalidArtifactKey(artifact_key.to_owned()));
973    }
974    Ok(())
975}
976
977fn validate_manifest_record(record: &ManifestRecord) -> Result<(), AliasError> {
978    match &record.entry {
979        ManifestEntry::Synthetic { name, .. } => {
980            if name.is_empty() || name.as_bytes().contains(&0) {
981                return Err(AliasError::InvalidManifestEntry(
982                    "synthetic entry name must be non-empty and NUL-free".to_owned(),
983                ));
984            }
985            let expected = [vec![0], name.as_bytes().to_vec()].concat();
986            if record.rel_path != expected {
987                return Err(AliasError::InvalidManifestEntry(
988                    "synthetic entry path must be a leading NUL followed by its name".to_owned(),
989                ));
990            }
991        }
992        ManifestEntry::Regular { mode, .. } => {
993            validate_rel_path(&record.rel_path)?;
994            if !mode.is_regular() {
995                return Err(AliasError::InvalidManifestEntry(
996                    "regular manifest entry must use mode 100644 or 100755".to_owned(),
997                ));
998            }
999        }
1000        ManifestEntry::Symlink { .. } | ManifestEntry::Gitlink { .. } => {
1001            validate_rel_path(&record.rel_path)?;
1002        }
1003    }
1004    Ok(())
1005}
1006
1007fn validate_rel_path(path: &[u8]) -> Result<(), AliasError> {
1008    if path.is_empty() {
1009        return Err(AliasError::InvalidRelativePath("path is empty".to_owned()));
1010    }
1011    if path[0] == b'/' {
1012        return Err(AliasError::InvalidRelativePath(
1013            "path is absolute".to_owned(),
1014        ));
1015    }
1016    if path.contains(&b'\\') {
1017        return Err(AliasError::InvalidRelativePath(
1018            "path must use `/` separators".to_owned(),
1019        ));
1020    }
1021    if path.contains(&0) {
1022        return Err(AliasError::InvalidRelativePath(
1023            "filesystem path contains NUL".to_owned(),
1024        ));
1025    }
1026    if path.len() >= 3 && path[0].is_ascii_alphabetic() && path[1] == b':' && path[2] == b'/' {
1027        return Err(AliasError::InvalidRelativePath(
1028            "path is drive-absolute".to_owned(),
1029        ));
1030    }
1031    if path
1032        .split(|byte| *byte == b'/')
1033        .any(|component| component.is_empty() || matches!(component, b"." | b".."))
1034    {
1035        return Err(AliasError::InvalidRelativePath(
1036            "path has an empty, `.` or `..` component".to_owned(),
1037        ));
1038    }
1039    Ok(())
1040}
1041
1042fn entry_json_object(entry: &ManifestEntry) -> serde_json::Map<String, serde_json::Value> {
1043    let mut object = serde_json::Map::new();
1044    match entry {
1045        ManifestEntry::Regular {
1046            mode,
1047            planes,
1048            resolution_input,
1049        } => {
1050            object.insert(
1051                "kind".to_owned(),
1052                serde_json::Value::String("regular".to_owned()),
1053            );
1054            object.insert(
1055                "mode".to_owned(),
1056                serde_json::Value::String(String::from_utf8_lossy(mode.as_bytes()).into_owned()),
1057            );
1058            object.insert(
1059                "planes".to_owned(),
1060                serde_json::json!({
1061                    "semantic": planes.semantic.map(|key| hex(&key)),
1062                    "callgraph": planes.callgraph.map(|key| hex(&key)),
1063                }),
1064            );
1065            object.insert(
1066                "resolution_input".to_owned(),
1067                serde_json::Value::Bool(*resolution_input),
1068            );
1069        }
1070        ManifestEntry::Symlink { target_bytes } => {
1071            object.insert(
1072                "kind".to_owned(),
1073                serde_json::Value::String("symlink".to_owned()),
1074            );
1075            object.insert("target_bytes".to_owned(), json_bytes(target_bytes));
1076        }
1077        ManifestEntry::Gitlink { oid } => {
1078            object.insert(
1079                "kind".to_owned(),
1080                serde_json::Value::String("gitlink".to_owned()),
1081            );
1082            object.insert("oid".to_owned(), serde_json::Value::String(oid.to_hex()));
1083        }
1084        ManifestEntry::Synthetic { name, callgraph } => {
1085            object.insert(
1086                "kind".to_owned(),
1087                serde_json::Value::String("synthetic".to_owned()),
1088            );
1089            object.insert("name".to_owned(), serde_json::Value::String(name.clone()));
1090            object.insert(
1091                "planes".to_owned(),
1092                serde_json::json!({ "callgraph": hex(callgraph) }),
1093            );
1094        }
1095    }
1096    object
1097}
1098
1099fn json_bytes(bytes: &[u8]) -> serde_json::Value {
1100    match std::str::from_utf8(bytes) {
1101        Ok(value) => serde_json::Value::String(value.to_owned()),
1102        Err(_) => serde_json::json!({ "b64": BASE64.encode(bytes) }),
1103    }
1104}
1105
1106fn hex(bytes: &[u8]) -> String {
1107    let mut result = String::with_capacity(bytes.len() * 2);
1108    for byte in bytes {
1109        use fmt::Write as _;
1110        let _ = write!(result, "{byte:02x}");
1111    }
1112    result
1113}
1114
1115fn hex_nibble(byte: u8) -> Option<u8> {
1116    match byte {
1117        b'0'..=b'9' => Some(byte - b'0'),
1118        b'a'..=b'f' => Some(byte - b'a' + 10),
1119        b'A'..=b'F' => Some(byte - b'A' + 10),
1120        _ => None,
1121    }
1122}
1123
1124fn path_display(path: &[u8]) -> String {
1125    String::from_utf8_lossy(path).into_owned()
1126}
1127
1128#[cfg(all(test, unix))]
1129mod subprocess_tests {
1130    use super::*;
1131
1132    #[test]
1133    fn command_input_and_output_larger_than_pipe_capacity_do_not_deadlock() {
1134        let bytes = 4 * 1024 * 1024;
1135        let started_at = Instant::now();
1136        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
1137        std::thread::spawn(move || {
1138            let result = run_command_with_input(
1139                Command::new("sh")
1140                    .arg("-c")
1141                    .arg("dd if=/dev/zero bs=1048576 count=4 2>/dev/null; cat >/dev/null"),
1142                vec![b'x'; bytes],
1143                Duration::from_secs(5),
1144            );
1145            let _ = done_tx.send(result);
1146        });
1147        let output = done_rx
1148            .recv_timeout(Duration::from_secs(5))
1149            .expect("subprocess communication deadlocked past the bounded test deadline")
1150            .expect("concurrent pipe pumps must finish before the subprocess deadline");
1151
1152        assert!(output.status.success());
1153        assert_eq!(output.stdout.len(), bytes);
1154        assert!(
1155            started_at.elapsed() < Duration::from_secs(5),
1156            "subprocess communication reached its deadline"
1157        );
1158    }
1159}