1use 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
21pub const PATH_IDENTITY_VERSION: u32 = 1;
23
24const GIT_METADATA_TIMEOUT: Duration = Duration::from_secs(30);
25
26#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
136pub struct GitOid([u8; 20]);
137
138impl GitOid {
139 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 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 pub const fn as_bytes(&self) -> &[u8; 20] {
166 &self.0
167 }
168
169 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#[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 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 pub const fn is_regular(&self) -> bool {
205 matches!(self, Self::Regular { .. })
206 }
207
208 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#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct TrackedPath {
223 pub rel_path: Vec<u8>,
225 pub mode: GitMode,
226 pub git_oid: GitOid,
227 pub filtered: bool,
229 pub present_in_previous_generation: bool,
231 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 pub fn is_alias_eligible(&self) -> bool {
283 self.tracked && self.mode.is_regular() && !self.filtered
284 }
285}
286
287#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
301pub enum AliasWrite {
302 Inserted([u8; 32]),
303 Reused([u8; 32]),
304 Skipped(AliasSkip),
305}
306
307pub struct AliasStore {
309 path: PathBuf,
310 connection: Connection,
311}
312
313impl AliasStore {
314 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 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 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 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 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#[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#[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 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
540pub struct PlaneKeys {
541 pub semantic: Option<[u8; 32]>,
542 pub callgraph: Option<[u8; 32]>,
543}
544
545#[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#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct ManifestRecord {
578 pub rel_path: Vec<u8>,
579 pub entry: ManifestEntry,
580}
581
582#[derive(Clone, Debug, Eq, PartialEq)]
584pub struct Manifest {
585 pub path_identity_version: u32,
586 pub entries: Vec<ManifestRecord>,
587}
588
589impl Manifest {
590 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 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
630pub 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 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 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
695pub 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
708pub 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
773pub 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 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 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}