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