1#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use crate::{ExecuteError, GenerationPlan, PackageSchemas, execute::read_optional_file};
6use boxology_manifest::RelativePath;
7use boxology_workspace::{
8 CargoManifestChange, DiffOwnership, FileEntry, Findings, Package, WorkspaceInputs,
9};
10use std::{
11 collections::BTreeMap,
12 fmt, fs, io,
13 path::Path,
14 process::{Command, Output},
15};
16
17type Rule = (&'static str, &'static str, &'static str);
18const BASE_GIT_SOURCE: &str = "specs/s5-manifest-and-validation.md D6";
19const BASE_REVISION_TEXT: &str = "the explicit base revision must resolve to a Git commit";
20const BASE_SCHEMA_TEXT: &str = "a base-revision schema object must be readable as a Git blob";
21const REVISION: Rule = ("BXW0091", BASE_REVISION_TEXT, BASE_GIT_SOURCE);
22const BASE_SCHEMA: Rule = ("BXW0092", BASE_SCHEMA_TEXT, BASE_GIT_SOURCE);
23const BASE_DISCOVERY_SOURCE: &str =
24 "boxology-details/02-packages.md discovery walk; specs/s5-manifest-and-validation.md D6";
25const BASE_LISTING_TEXT: &str =
26 "the base revision's Git listings must parse as expected NUL-delimited output";
27const BASE_BLOB_TEXT: &str = "a base-revision workspace object must be readable as a Git blob";
28const BASE_DECLARATIONS_TEXT: &str =
29 "the base revision's package declarations must form a discoverable workspace";
30const CANDIDATE_CARGO_TEXT: &str =
31 "a changed candidate Cargo manifest must be a readable regular file when present";
32const BASE_LISTING: Rule = ("BXW0103", BASE_LISTING_TEXT, BASE_GIT_SOURCE);
33const BASE_BLOB: Rule = ("BXW0104", BASE_BLOB_TEXT, BASE_GIT_SOURCE);
34const BASE_DECLARATIONS: Rule = ("BXW0105", BASE_DECLARATIONS_TEXT, BASE_DISCOVERY_SOURCE);
35const CANDIDATE_CARGO: Rule = ("BXW0106", CANDIDATE_CARGO_TEXT, BASE_GIT_SOURCE);
36
37#[derive(Debug, Eq, PartialEq)]
39pub struct GitToolError;
40
41impl fmt::Display for GitToolError {
42 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43 formatter.write_str("git could not be executed")
44 }
45}
46
47impl std::error::Error for GitToolError {}
48
49#[derive(Debug, Eq, PartialEq)]
51pub struct BaseError {
52 code: &'static str,
53 location: String,
54 detail: &'static str,
55}
56
57impl BaseError {
58 pub fn code(&self) -> &'static str {
60 self.code
61 }
62 pub fn location(&self) -> &str {
64 &self.location
65 }
66 pub fn detail(&self) -> &'static str {
68 self.detail
69 }
70
71 fn revision() -> Self {
72 Self {
73 code: REVISION.0,
74 location: ".git".to_owned(),
75 detail: REVISION.1,
76 }
77 }
78 fn schema(plan: &GenerationPlan) -> Self {
79 Self {
80 code: BASE_SCHEMA.0,
81 location: plan.schema_path().as_str().to_owned(),
82 detail: BASE_SCHEMA.1,
83 }
84 }
85 fn at(rule: Rule, location: impl Into<String>) -> Self {
86 Self {
87 code: rule.0,
88 location: location.into(),
89 detail: rule.1,
90 }
91 }
92 fn listing() -> Self {
93 Self::at(BASE_LISTING, ".git")
94 }
95 fn blob(path: &RelativePath) -> Self {
96 Self::at(BASE_BLOB, path.as_str())
97 }
98 fn declarations() -> Self {
99 Self::at(BASE_DECLARATIONS, ".git")
100 }
101 fn cargo(path: &RelativePath) -> Self {
102 Self::at(CANDIDATE_CARGO, path.as_str())
103 }
104}
105
106impl fmt::Display for BaseError {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 write!(
109 formatter,
110 "{} {}: {}",
111 self.code, self.location, self.detail
112 )
113 }
114}
115
116impl std::error::Error for BaseError {}
117
118#[derive(Debug)]
120pub enum BaseSchemasError {
121 Tool(GitToolError),
123 Git(BaseError),
125 Submitted(ExecuteError),
127}
128
129#[derive(Debug)]
131pub enum BaseInputsError {
132 Tool(GitToolError),
134 Data(BaseError),
136 Declarations {
138 header: BaseError,
140 findings: Findings,
142 },
143}
144impl fmt::Display for BaseInputsError {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 match self {
147 Self::Tool(e) => e.fmt(f),
148 Self::Data(e) => e.fmt(f),
149 Self::Declarations { header, findings } => write!(f, "{header}\n{findings}"),
150 }
151 }
152}
153impl std::error::Error for BaseInputsError {}
154
155#[derive(Debug, Eq, PartialEq)]
157pub enum DefaultBase {
158 Commit(String),
160 NoRepository,
162 NoMergeBase,
164}
165
166#[derive(Debug, Eq, PartialEq)]
168pub struct ResolvedBase(String);
169impl ResolvedBase {
170 pub fn as_str(&self) -> &str {
172 &self.0
173 }
174 pub fn from_oid(oid: String) -> Result<Self, BaseError> {
179 let oid = oid.trim();
180 valid_oid(oid)
181 .then(|| Self(oid.to_owned()))
182 .ok_or_else(BaseError::revision)
183 }
184}
185
186pub fn resolve_default_base(root: &Path) -> Result<DefaultBase, GitToolError> {
191 let git_dir = git(root, &["rev-parse", "--git-dir"])
192 .output()
193 .map_err(|_| GitToolError)?;
194 if !git_dir.status.success() {
195 return Ok(DefaultBase::NoRepository);
196 }
197 let merge = git(root, &["merge-base", "HEAD", "main"])
198 .output()
199 .map_err(|_| GitToolError)?;
200 if !merge.status.success() {
201 return Ok(DefaultBase::NoMergeBase);
202 }
203 let oid = std::str::from_utf8(&merge.stdout)
204 .map(str::trim)
205 .unwrap_or("")
206 .to_owned();
207 Ok(DefaultBase::Commit(oid))
208}
209
210pub fn resolve_base(root: &Path, revision: &str) -> Result<ResolvedBase, BaseSchemasError> {
215 Ok(ResolvedBase(resolve_commit(root, revision)?))
216}
217
218pub fn base_package_schemas(
227 root: &Path,
228 base: &ResolvedBase,
229 plans: &[GenerationPlan],
230) -> Result<Vec<PackageSchemas>, BaseSchemasError> {
231 let oid = base.as_str();
232 plans
233 .iter()
234 .map(|plan| {
235 let submitted = read_optional_file(root, plan.schema_path())
236 .map_err(BaseSchemasError::Submitted)?
237 .ok_or_else(|| {
238 BaseSchemasError::Submitted(crate::execute::missing_schema(root, plan))
239 })?;
240 let base = read_schema(root, oid, plan)?;
241 Ok(PackageSchemas::new(
242 plan.package_id().clone(),
243 base,
244 submitted,
245 ))
246 })
247 .collect()
248}
249
250fn resolve_commit(root: &Path, revision: &str) -> Result<String, BaseSchemasError> {
251 let requested = format!("{revision}^{{commit}}");
252 let output = git(
253 root,
254 &["rev-parse", "--verify", "--end-of-options", &requested],
255 )
256 .output()
257 .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
258 if !output.status.success() {
259 return Err(BaseSchemasError::Git(BaseError::revision()));
260 }
261 let oid = std::str::from_utf8(&output.stdout)
262 .ok()
263 .map(str::trim)
264 .filter(|oid| valid_oid(oid))
265 .ok_or_else(|| BaseSchemasError::Git(BaseError::revision()))?;
266 Ok(oid.to_owned())
267}
268
269fn read_schema(
270 root: &Path,
271 oid: &str,
272 plan: &GenerationPlan,
273) -> Result<Option<Vec<u8>>, BaseSchemasError> {
274 let object = format!("{oid}:./{}", plan.schema_path().as_str());
277 let listed = git(
278 root,
279 &[
280 "ls-tree",
281 "--name-only",
282 "-z",
283 oid,
284 "--",
285 plan.schema_path().as_str(),
286 ],
287 )
288 .output()
289 .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
290 if !listed.status.success() {
291 return Err(BaseSchemasError::Git(BaseError::schema(plan)));
292 }
293 if listed.stdout.is_empty() {
294 return Ok(None);
295 }
296 let mut expected = plan.schema_path().as_str().as_bytes().to_vec();
297 expected.push(0);
298 if listed.stdout != expected {
299 return Err(BaseSchemasError::Git(BaseError::schema(plan)));
300 }
301 let exists = git(root, &["cat-file", "-e", &object])
302 .output()
303 .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
304 if !exists.status.success() {
305 return Err(BaseSchemasError::Git(BaseError::schema(plan)));
306 }
307 let output = git(root, &["cat-file", "blob", &object])
308 .output()
309 .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
310 if !output.status.success() {
311 return Err(BaseSchemasError::Git(BaseError::schema(plan)));
312 }
313 Ok(Some(output.stdout))
314}
315
316#[derive(Debug)]
318pub struct BaseDiffInputs {
319 packages: Vec<Package>,
320 changed: Vec<RelativePath>,
321 bootstrapping: bool,
322 objects: BTreeMap<RelativePath, (TreeKind, String)>,
324}
325impl BaseDiffInputs {
326 pub fn packages(&self) -> &[Package] {
328 &self.packages
329 }
330 pub fn changed(&self) -> &[RelativePath] {
332 &self.changed
333 }
334 pub fn is_bootstrapping(&self) -> bool {
336 self.bootstrapping
337 }
338 pub fn manifest_changes(
343 &self,
344 root: &Path,
345 ownership: &DiffOwnership,
346 ) -> Result<Vec<CargoManifestChange>, BaseInputsError> {
347 let Some(accountable) = ownership.accountable() else {
348 return Ok(Vec::new());
349 };
350 if ownership
351 .classifications()
352 .iter()
353 .all(|held| held.path().as_str() != "Cargo.lock")
354 {
355 return Ok(Vec::new());
356 }
357 ownership
358 .classifications()
359 .iter()
360 .filter(|held| {
361 held.package() == accountable
362 && held.path().as_str().rsplit('/').next() == Some("Cargo.toml")
363 })
364 .map(|held| {
365 let path = held.path();
366 let base = match self.objects.get(path) {
367 Some((TreeKind::Gitlink, _)) | None => None,
368 Some((_, oid)) => Some(read_blob(root, oid, path)?),
369 };
370 Ok(CargoManifestChange::new(
371 path.clone(),
372 base,
373 read_candidate_cargo(root, path)?,
374 ))
375 })
376 .collect()
377 }
378}
379
380pub fn base_diff_inputs(
387 root: &Path,
388 base: &ResolvedBase,
389) -> Result<BaseDiffInputs, BaseInputsError> {
390 base_diff_inputs_inner(root, base, None)
391}
392
393pub fn base_diff_inputs_with_candidate(
402 root: &Path,
403 base: &ResolvedBase,
404 candidate_files: &[FileEntry],
405 candidate_manifests: &[(RelativePath, Vec<u8>)],
406) -> Result<BaseDiffInputs, BaseInputsError> {
407 base_diff_inputs_inner(
408 root,
409 base,
410 Some(CandidateDeclarations {
411 files: candidate_files,
412 manifests: candidate_manifests,
413 }),
414 )
415}
416
417struct CandidateDeclarations<'a> {
418 files: &'a [FileEntry],
419 manifests: &'a [(RelativePath, Vec<u8>)],
420}
421
422fn base_diff_inputs_inner(
423 root: &Path,
424 base: &ResolvedBase,
425 candidate: Option<CandidateDeclarations<'_>>,
426) -> Result<BaseDiffInputs, BaseInputsError> {
427 let listed = git_ok(root, &["ls-tree", "-r", "-z", base.as_str(), "--", "."])?;
428 let mut files = Vec::new();
429 let mut manifests = Vec::new();
430 let mut objects = BTreeMap::new();
431 for entry in parse_nul(&listed.stdout, parse_tree)? {
432 if objects
433 .insert(entry.path.clone(), (entry.kind, entry.oid.clone()))
434 .is_some()
435 {
436 return Err(data(BaseError::listing()));
437 }
438 match entry.kind {
440 TreeKind::Gitlink => {}
441 TreeKind::File | TreeKind::Executable => {
442 if entry.path.as_str().rsplit('/').next() == Some("boxology.toml") {
443 manifests.push((
444 entry.path.clone(),
445 read_blob(root, &entry.oid, &entry.path)?,
446 ));
447 }
448 files.push(FileEntry::file(entry.path));
449 }
450 TreeKind::Symlink => {
451 let target = String::from_utf8(read_blob(root, &entry.oid, &entry.path)?)
452 .map_err(|_| data(BaseError::blob(&entry.path)))?;
453 files.push(FileEntry::symlink(entry.path, target));
454 }
455 }
456 }
457 let diffed = git_ok(
458 root,
459 &[
460 "diff",
461 "--name-only",
462 "--relative",
463 "-z",
464 "--no-renames",
465 "--no-ext-diff",
466 base.as_str(),
467 "--",
468 ".",
469 ],
470 )?;
471 let mut changed = parse_nul(&diffed.stdout, parse_path)?;
472 changed.sort();
473 changed.dedup();
474 let bootstrapping = candidate.is_some() && objects.is_empty();
475 let (files, manifests) = match candidate.filter(|_| bootstrapping) {
476 Some(candidate) => (candidate.files.to_vec(), candidate.manifests.to_vec()),
477 None => (files, manifests),
478 };
479 let inputs =
480 WorkspaceInputs::new(files, manifests, "").map_err(|_| data(BaseError::listing()))?;
481 let (packages, findings) = inputs.discover();
482 if let Some(findings) = findings {
483 return Err(BaseInputsError::Declarations {
484 header: BaseError::declarations(),
485 findings,
486 });
487 }
488 Ok(BaseDiffInputs {
489 packages,
490 changed,
491 bootstrapping,
492 objects,
493 })
494}
495
496#[derive(Clone, Copy, Debug, Eq, PartialEq)]
497enum TreeKind {
498 File,
499 Executable,
500 Symlink,
501 Gitlink,
502}
503struct TreeEntry {
504 kind: TreeKind,
505 oid: String,
506 path: RelativePath,
507}
508
509fn data(error: BaseError) -> BaseInputsError {
510 BaseInputsError::Data(error)
511}
512fn git_ok(root: &Path, args: &[&str]) -> Result<Output, BaseInputsError> {
513 let output = git(root, args)
514 .output()
515 .map_err(|_| BaseInputsError::Tool(GitToolError))?;
516 output
517 .status
518 .success()
519 .then_some(output)
520 .ok_or_else(|| data(BaseError::listing()))
521}
522fn read_blob(root: &Path, oid: &str, path: &RelativePath) -> Result<Vec<u8>, BaseInputsError> {
523 let output = git(root, &["cat-file", "blob", oid])
524 .output()
525 .map_err(|_| BaseInputsError::Tool(GitToolError))?;
526 output
527 .status
528 .success()
529 .then_some(output.stdout)
530 .ok_or_else(|| data(BaseError::blob(path)))
531}
532fn read_candidate_cargo(
533 root: &Path,
534 path: &RelativePath,
535) -> Result<Option<Vec<u8>>, BaseInputsError> {
536 let at = root.join(path.as_str());
537 match fs::symlink_metadata(&at) {
538 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
539 Ok(meta) if meta.file_type().is_file() => fs::read(&at)
540 .map(Some)
541 .map_err(|_| data(BaseError::cargo(path))),
542 _ => Err(data(BaseError::cargo(path))),
543 }
544}
545fn parse_nul<T>(
546 stdout: &[u8],
547 parse: fn(&[u8]) -> Result<T, BaseInputsError>,
548) -> Result<Vec<T>, BaseInputsError> {
549 if stdout.is_empty() {
550 return Ok(Vec::new());
551 }
552 if stdout.last() != Some(&0) {
553 return Err(data(BaseError::listing()));
554 }
555 stdout[..stdout.len() - 1]
556 .split(|byte| *byte == 0)
557 .map(|record| {
558 if record.is_empty() {
559 Err(data(BaseError::listing()))
560 } else {
561 parse(record)
562 }
563 })
564 .collect()
565}
566fn parse_tree(record: &[u8]) -> Result<TreeEntry, BaseInputsError> {
567 let tab = record
568 .iter()
569 .position(|byte| *byte == b'\t')
570 .ok_or_else(|| data(BaseError::listing()))?;
571 let meta = std::str::from_utf8(&record[..tab]).map_err(|_| data(BaseError::listing()))?;
572 let mut parts = meta.split(' ');
573 let (Some(mode), Some(kind), Some(oid), None) =
574 (parts.next(), parts.next(), parts.next(), parts.next())
575 else {
576 return Err(data(BaseError::listing()));
577 };
578 if !valid_oid(oid) {
579 return Err(data(BaseError::listing()));
580 }
581 let kind = match (mode, kind) {
582 ("100644", "blob") => TreeKind::File,
583 ("100755", "blob") => TreeKind::Executable,
584 ("120000", "blob") => TreeKind::Symlink,
585 ("160000", "commit") => TreeKind::Gitlink,
586 _ => return Err(data(BaseError::listing())),
587 };
588 Ok(TreeEntry {
589 kind,
590 oid: oid.to_owned(),
591 path: parse_path(&record[tab + 1..])?,
592 })
593}
594fn parse_path(bytes: &[u8]) -> Result<RelativePath, BaseInputsError> {
595 let text = std::str::from_utf8(bytes).map_err(|_| data(BaseError::listing()))?;
596 RelativePath::new(text.to_owned()).map_err(|_| data(BaseError::listing()))
597}
598fn valid_oid(oid: &str) -> bool {
599 (oid.len() == 40 || oid.len() == 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
600}
601fn git(root: &Path, args: &[&str]) -> Command {
602 let mut command = Command::new("git");
603 command.args(args).current_dir(root);
604 command
605}