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};
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
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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
119pub struct GitOid([u8; 20]);
120
121impl GitOid {
122 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 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 pub const fn as_bytes(&self) -> &[u8; 20] {
149 &self.0
150 }
151
152 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#[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 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 pub const fn is_regular(&self) -> bool {
188 matches!(self, Self::Regular { .. })
189 }
190
191 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#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct TrackedPath {
206 pub rel_path: Vec<u8>,
208 pub mode: GitMode,
209 pub git_oid: GitOid,
210 pub filtered: bool,
212 pub present_in_previous_generation: bool,
214 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 pub fn is_alias_eligible(&self) -> bool {
244 self.tracked && self.mode.is_regular() && !self.filtered
245 }
246}
247
248#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
262pub enum AliasWrite {
263 Inserted([u8; 32]),
264 Reused([u8; 32]),
265 Skipped(AliasSkip),
266}
267
268pub struct AliasStore {
270 path: PathBuf,
271 connection: Connection,
272}
273
274impl AliasStore {
275 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 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 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 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 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#[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#[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 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
501pub struct PlaneKeys {
502 pub semantic: Option<[u8; 32]>,
503 pub callgraph: Option<[u8; 32]>,
504}
505
506#[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#[derive(Clone, Debug, Eq, PartialEq)]
538pub struct ManifestRecord {
539 pub rel_path: Vec<u8>,
540 pub entry: ManifestEntry,
541}
542
543#[derive(Clone, Debug, Eq, PartialEq)]
545pub struct Manifest {
546 pub path_identity_version: u32,
547 pub entries: Vec<ManifestRecord>,
548}
549
550impl Manifest {
551 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 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
591pub 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 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 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
656pub 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
669pub fn is_lfs_pointer(bytes: &[u8]) -> bool {
672 bytes.starts_with(b"version https://git-lfs.github.com/spec/v1\n")
673}
674
675pub 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 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 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}