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 objects: BTreeMap<RelativePath, (TreeKind, String)>,
323}
324impl BaseDiffInputs {
325 pub fn packages(&self) -> &[Package] {
327 &self.packages
328 }
329 pub fn changed(&self) -> &[RelativePath] {
331 &self.changed
332 }
333 pub fn manifest_changes(
338 &self,
339 root: &Path,
340 ownership: &DiffOwnership,
341 ) -> Result<Vec<CargoManifestChange>, BaseInputsError> {
342 let Some(accountable) = ownership.accountable() else {
343 return Ok(Vec::new());
344 };
345 if ownership
346 .classifications()
347 .iter()
348 .all(|held| held.path().as_str() != "Cargo.lock")
349 {
350 return Ok(Vec::new());
351 }
352 ownership
353 .classifications()
354 .iter()
355 .filter(|held| {
356 held.package() == accountable
357 && held.path().as_str().rsplit('/').next() == Some("Cargo.toml")
358 })
359 .map(|held| {
360 let path = held.path();
361 let base = match self.objects.get(path) {
362 Some((TreeKind::Gitlink, _)) | None => None,
363 Some((_, oid)) => Some(read_blob(root, oid, path)?),
364 };
365 Ok(CargoManifestChange::new(
366 path.clone(),
367 base,
368 read_candidate_cargo(root, path)?,
369 ))
370 })
371 .collect()
372 }
373}
374
375pub fn base_diff_inputs(
382 root: &Path,
383 base: &ResolvedBase,
384) -> Result<BaseDiffInputs, BaseInputsError> {
385 let listed = git_ok(root, &["ls-tree", "-r", "-z", base.as_str(), "--", "."])?;
386 let mut files = Vec::new();
387 let mut manifests = Vec::new();
388 let mut objects = BTreeMap::new();
389 for entry in parse_nul(&listed.stdout, parse_tree)? {
390 if objects
391 .insert(entry.path.clone(), (entry.kind, entry.oid.clone()))
392 .is_some()
393 {
394 return Err(data(BaseError::listing()));
395 }
396 match entry.kind {
398 TreeKind::Gitlink => {}
399 TreeKind::File | TreeKind::Executable => {
400 if entry.path.as_str().rsplit('/').next() == Some("boxology.toml") {
401 manifests.push((
402 entry.path.clone(),
403 read_blob(root, &entry.oid, &entry.path)?,
404 ));
405 }
406 files.push(FileEntry::file(entry.path));
407 }
408 TreeKind::Symlink => {
409 let target = String::from_utf8(read_blob(root, &entry.oid, &entry.path)?)
410 .map_err(|_| data(BaseError::blob(&entry.path)))?;
411 files.push(FileEntry::symlink(entry.path, target));
412 }
413 }
414 }
415 let diffed = git_ok(
416 root,
417 &[
418 "diff",
419 "--name-only",
420 "--relative",
421 "-z",
422 "--no-renames",
423 "--no-ext-diff",
424 base.as_str(),
425 "--",
426 ".",
427 ],
428 )?;
429 let mut changed = parse_nul(&diffed.stdout, parse_path)?;
430 changed.sort();
431 changed.dedup();
432 let inputs =
433 WorkspaceInputs::new(files, manifests, "").map_err(|_| data(BaseError::listing()))?;
434 let (packages, findings) = inputs.discover();
435 if let Some(findings) = findings {
436 return Err(BaseInputsError::Declarations {
437 header: BaseError::declarations(),
438 findings,
439 });
440 }
441 Ok(BaseDiffInputs {
442 packages,
443 changed,
444 objects,
445 })
446}
447
448#[derive(Clone, Copy, Debug, Eq, PartialEq)]
449enum TreeKind {
450 File,
451 Executable,
452 Symlink,
453 Gitlink,
454}
455struct TreeEntry {
456 kind: TreeKind,
457 oid: String,
458 path: RelativePath,
459}
460
461fn data(error: BaseError) -> BaseInputsError {
462 BaseInputsError::Data(error)
463}
464fn git_ok(root: &Path, args: &[&str]) -> Result<Output, BaseInputsError> {
465 let output = git(root, args)
466 .output()
467 .map_err(|_| BaseInputsError::Tool(GitToolError))?;
468 output
469 .status
470 .success()
471 .then_some(output)
472 .ok_or_else(|| data(BaseError::listing()))
473}
474fn read_blob(root: &Path, oid: &str, path: &RelativePath) -> Result<Vec<u8>, BaseInputsError> {
475 let output = git(root, &["cat-file", "blob", oid])
476 .output()
477 .map_err(|_| BaseInputsError::Tool(GitToolError))?;
478 output
479 .status
480 .success()
481 .then_some(output.stdout)
482 .ok_or_else(|| data(BaseError::blob(path)))
483}
484fn read_candidate_cargo(
485 root: &Path,
486 path: &RelativePath,
487) -> Result<Option<Vec<u8>>, BaseInputsError> {
488 let at = root.join(path.as_str());
489 match fs::symlink_metadata(&at) {
490 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
491 Ok(meta) if meta.file_type().is_file() => fs::read(&at)
492 .map(Some)
493 .map_err(|_| data(BaseError::cargo(path))),
494 _ => Err(data(BaseError::cargo(path))),
495 }
496}
497fn parse_nul<T>(
498 stdout: &[u8],
499 parse: fn(&[u8]) -> Result<T, BaseInputsError>,
500) -> Result<Vec<T>, BaseInputsError> {
501 if stdout.is_empty() {
502 return Ok(Vec::new());
503 }
504 if stdout.last() != Some(&0) {
505 return Err(data(BaseError::listing()));
506 }
507 stdout[..stdout.len() - 1]
508 .split(|byte| *byte == 0)
509 .map(|record| {
510 if record.is_empty() {
511 Err(data(BaseError::listing()))
512 } else {
513 parse(record)
514 }
515 })
516 .collect()
517}
518fn parse_tree(record: &[u8]) -> Result<TreeEntry, BaseInputsError> {
519 let tab = record
520 .iter()
521 .position(|byte| *byte == b'\t')
522 .ok_or_else(|| data(BaseError::listing()))?;
523 let meta = std::str::from_utf8(&record[..tab]).map_err(|_| data(BaseError::listing()))?;
524 let mut parts = meta.split(' ');
525 let (Some(mode), Some(kind), Some(oid), None) =
526 (parts.next(), parts.next(), parts.next(), parts.next())
527 else {
528 return Err(data(BaseError::listing()));
529 };
530 if !valid_oid(oid) {
531 return Err(data(BaseError::listing()));
532 }
533 let kind = match (mode, kind) {
534 ("100644", "blob") => TreeKind::File,
535 ("100755", "blob") => TreeKind::Executable,
536 ("120000", "blob") => TreeKind::Symlink,
537 ("160000", "commit") => TreeKind::Gitlink,
538 _ => return Err(data(BaseError::listing())),
539 };
540 Ok(TreeEntry {
541 kind,
542 oid: oid.to_owned(),
543 path: parse_path(&record[tab + 1..])?,
544 })
545}
546fn parse_path(bytes: &[u8]) -> Result<RelativePath, BaseInputsError> {
547 let text = std::str::from_utf8(bytes).map_err(|_| data(BaseError::listing()))?;
548 RelativePath::new(text.to_owned()).map_err(|_| data(BaseError::listing()))
549}
550fn valid_oid(oid: &str) -> bool {
551 (oid.len() == 40 || oid.len() == 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
552}
553fn git(root: &Path, args: &[&str]) -> Command {
554 let mut command = Command::new("git");
555 command.args(args).current_dir(root);
556 command
557}