1pub mod evidence;
32
33use std::collections::BTreeMap;
34
35use serde::{Deserialize, Serialize};
36
37use crate::embedded;
38use crate::error::RkError;
39pub use crate::landing::manifest::Provider;
40use crate::landing::{CheckoutMode, Integration, MergeForm, Params};
41use crate::profile::ReleaseMode;
42use crate::profile::catalog::{self, Availability, Selection, Status};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ProjectionInput {
48 pub params: Params,
50 pub evidence: TargetEvidence,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
57#[allow(
58 clippy::struct_excessive_bools,
59 reason = "each field is one independent fact read off the target, not a state a smaller type could carry"
60)]
61pub struct TargetEvidence {
62 pub documents: BTreeMap<String, Vec<u8>>,
66 pub crate_shape: CrateShape,
68 pub flake_nix_present: bool,
70 pub flake_lock_present: bool,
72 pub flake_recorded: bool,
75 pub root_pipeline_present: bool,
77 pub root_pipeline_recorded: bool,
80}
81
82impl TargetEvidence {
83 #[must_use]
85 pub fn document(&self, destination: &str) -> Option<&[u8]> {
86 self.documents.get(destination).map(Vec::as_slice)
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct CrateShape {
94 pub cargo_toml: Option<String>,
96 pub cargo_lock: bool,
98 pub main_rs: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Projection {
109 pub capabilities: Vec<Selection>,
112 pub candidates: Vec<Candidate>,
114 pub omissions: Vec<Omission>,
119 pub collisions: Vec<Collision>,
122 pub licence_refusal: Option<String>,
128 pub record_defects: Vec<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Candidate {
138 pub capability: &'static str,
140 pub destination: String,
142 pub kind: Kind,
144 pub placement: Placement,
146 pub bytes: Vec<u8>,
150 pub region: Option<Vec<u8>>,
153 pub sources: Vec<String>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum Placement {
161 Whole,
163 Region {
166 begin: &'static str,
168 end: &'static str,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Omission {
176 pub destination: String,
178 pub reason: String,
180 pub action: Option<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Collision {
189 pub destination: String,
191 pub reason: String,
193}
194
195impl Projection {
196 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
206 Self::compute_over(&embedded_snippets(), input)
207 }
208
209 #[allow(
213 clippy::too_many_lines,
214 reason = "one pass selects, renders, splices, and withholds; splitting it would separate a withheld destination from the selection that offered it"
215 )]
216 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
217 let params = &input.params;
218 let evidence = &input.evidence;
219 let availability = Availability::over(
220 files
221 .iter()
222 .filter_map(|(path, _)| path.strip_prefix("snippets/").map(str::to_owned))
223 .collect(),
224 );
225 let mut capabilities = catalog::select(params, &availability);
226 let mut candidates: Vec<Candidate> = Vec::new();
227 for selection in &capabilities {
228 if !selection.lands() {
229 continue;
230 }
231 for source in &selection.sources {
232 let destination = source
233 .splitn(3, '/')
234 .nth(2)
235 .ok_or_else(|| {
236 anyhow::anyhow!(
237 "{source}: a snippet path has a zone, a forge, and a destination"
238 )
239 })?
240 .to_owned();
241 let path = format!("snippets/{source}");
242 let bytes = files
243 .iter()
244 .find(|(candidate, _)| *candidate == path)
245 .map(|(_, bytes)| *bytes)
246 .ok_or_else(|| {
247 anyhow::anyhow!(
248 "{path}: the catalog selected a source the tree does not carry"
249 )
250 })?;
251 if let Some(existing) = candidates
252 .iter()
253 .find(|candidate| candidate.destination == destination)
254 {
255 return Err(anyhow::anyhow!(
256 "{} and {} both ship {destination}: {} and {path}; the embedded sources are defective",
257 existing.capability,
258 selection.id,
259 existing.sources.join(", ")
260 )
261 .into());
262 }
263 let kind = kind_of(&destination).ok_or_else(|| {
264 anyhow::anyhow!(
265 "the embedded sources do not classify {destination}; the kind table is stale"
266 )
267 })?;
268 let rendered = match kind {
269 Kind::Rendered => render(bytes, params),
270 Kind::Seeded | Kind::State => bytes.to_vec(),
271 };
272 candidates.push(Candidate {
273 capability: selection.id,
274 destination,
275 kind,
276 placement: Placement::Whole,
277 bytes: rendered,
278 region: None,
279 sources: vec![path],
280 });
281 }
282 }
283 let mut collisions = Vec::new();
284 for destination in BLOCK_DESTINATIONS {
285 let Some((template, sources, capability)) = block_template(destination, params)? else {
286 continue;
287 };
288 if let Some(whole) = candidates
289 .iter()
290 .find(|candidate| candidate.destination == destination)
291 {
292 return Err(anyhow::anyhow!(
293 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
294 whole.sources.join(", "),
295 sources.join(", ")
296 )
297 .into());
298 }
299 let region = render(template.as_bytes(), params);
300 let (begin, end) = block_markers(destination).ok_or_else(|| {
301 anyhow::anyhow!("{destination} is a block destination with no markers")
302 })?;
303 match propose_document(destination, evidence.document(destination), ®ion) {
304 Ok(bytes) => candidates.push(Candidate {
305 capability,
306 destination: destination.to_owned(),
307 kind: Kind::Rendered,
308 placement: Placement::Region { begin, end },
309 bytes,
310 region: Some(region),
311 sources,
312 }),
313 Err(reason) => collisions.push(Collision {
314 destination: destination.to_owned(),
315 reason,
316 }),
317 }
318 }
319 let mut omissions = Vec::new();
320 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
321 && candidates
322 .iter()
323 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
324 {
325 candidates.retain(|candidate| {
326 if set.contains(&candidate.destination.as_str()) {
327 omissions.push(Omission {
328 destination: candidate.destination.clone(),
329 reason: reason.clone(),
330 action: None,
331 });
332 false
333 } else {
334 true
335 }
336 });
337 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
338 }
339 let gate_root = candidates.iter().position(|candidate| {
345 (candidate.capability == catalog::TITLE_CHECK
346 || candidate.capability == catalog::COMMIT_CHECK)
347 && candidate.destination == GITLAB_ROOT_PIPELINE
348 });
349 if let Some(index) = gate_root
350 && evidence.root_pipeline_present
351 && !evidence.root_pipeline_recorded
352 {
353 let candidate = candidates.remove(index);
354 let (gate, fragment) = if candidate.capability == catalog::COMMIT_CHECK {
355 ("commit gate", GITLAB_COMMIT_FRAGMENT)
356 } else {
357 ("title gate", GITLAB_TITLE_FRAGMENT)
358 };
359 let reason = format!(
360 "the target owns {GITLAB_ROOT_PIPELINE}, so the release-less root pipeline that would activate the {gate} stays out; the fragment lands as the gate's prerequisite"
361 );
362 let action = format!(
363 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {fragment}"
364 );
365 omissions.push(Omission {
366 destination: candidate.destination,
367 reason: reason.clone(),
368 action: Some(action.clone()),
369 });
370 withhold(
371 &mut capabilities,
372 candidate.capability,
373 &reason,
374 Some(action),
375 );
376 }
377 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
378 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
379 let scanning_selected = capabilities.iter().any(|selection| {
385 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
386 });
387 let licence_refusal = scanning_selected
388 .then(|| {
389 code_scanning_licence_refusal(
390 params.code_scanning(),
391 params.driver(),
392 &evidence.crate_shape,
393 )
394 })
395 .flatten();
396 let mut record_defects: Vec<String> = Vec::new();
401 if params.release_mode() == ReleaseMode::Automatic
405 && let Some(selection) = capabilities
406 .iter()
407 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
408 && matches!(selection.status, Status::Unavailable | Status::Unknown)
409 && let Some(reason) = &selection.reason
410 {
411 record_defects.push(reason.clone());
412 }
413 Ok(Self {
414 capabilities,
415 candidates,
416 omissions,
417 collisions,
418 licence_refusal,
419 record_defects,
420 })
421 }
422
423 #[must_use]
427 pub fn release_unavailable(&self) -> Option<&str> {
428 self.capabilities
429 .iter()
430 .find(|selection| {
431 selection.id == catalog::RELEASE_AUTOMATION
432 && matches!(selection.status, Status::Unavailable | Status::Unknown)
433 })
434 .and_then(|selection| selection.reason.as_deref())
435 }
436
437 #[must_use]
439 pub fn capability(&self, id: &str) -> Option<&Selection> {
440 self.capabilities
441 .iter()
442 .find(|selection| selection.id == id)
443 }
444}
445
446fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
448 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
449 selection.status = Status::Withheld;
450 selection.reason = Some(reason.to_owned());
451 selection.action = action;
452 }
453}
454
455pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
458
459pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
461
462pub const GITLAB_COMMIT_FRAGMENT: &str = ".gitlab/ci/mr-commits.yml";
464
465fn embedded_snippets() -> Vec<(String, &'static [u8])> {
468 embedded::walk(&embedded::SNIPPETS)
469 .into_iter()
470 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
471 .collect()
472}
473
474#[must_use]
477pub fn supported_pairs() -> Vec<(String, String)> {
478 Availability::embedded()
479 .automation_tuples()
480 .into_iter()
481 .filter_map(|entry| {
482 entry
483 .split_once(", ")
484 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
485 })
486 .collect()
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "lowercase")]
492pub enum Kind {
493 Rendered,
496 Seeded,
499 State,
502}
503
504impl Kind {
505 #[must_use]
507 pub const fn as_str(self) -> &'static str {
508 match self {
509 Self::Rendered => "rendered",
510 Self::Seeded => "seeded",
511 Self::State => "state",
512 }
513 }
514}
515
516const KINDS: [(&str, Kind); 22] = [
522 (".github/workflows/release-plz.yml", Kind::Rendered),
523 (".github/workflows/release-please.yml", Kind::Rendered),
524 (".github/workflows/release.yml", Kind::Rendered),
525 (".github/workflows/pr-title.yml", Kind::Rendered),
526 (".github/workflows/pr-commits.yml", Kind::Rendered),
527 (".github/workflows/scorecard.yml", Kind::Rendered),
528 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
529 (
530 ".github/workflows/code-scanning-semgrep.yml",
531 Kind::Rendered,
532 ),
533 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
534 (".gitlab-ci.yml", Kind::Rendered),
535 ("SECURITY.md", Kind::Rendered),
536 (".gitlab/ci/mr-title.yml", Kind::Rendered),
537 (".gitlab/ci/mr-commits.yml", Kind::Rendered),
538 ("release-plz.toml", Kind::Seeded),
539 ("dist-workspace.toml", Kind::Seeded),
540 ("release-please-config.json", Kind::Seeded),
541 ("cliff.toml", Kind::Seeded),
542 ("nix/package.nix", Kind::Seeded),
543 ("flake.nix", Kind::Seeded),
544 (".release-please-manifest.json", Kind::State),
545 ("VERSION", Kind::State),
546 ("flake.lock", Kind::State),
547];
548
549pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
563
564pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
570
571pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
586
587pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
601 (
602 ".github/workflows/code-scanning-codeql.yml",
603 Provider::CodeQl,
604 ),
605 (
606 ".github/workflows/code-scanning-semgrep.yml",
607 Provider::Semgrep,
608 ),
609 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
610];
611
612pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
620
621#[must_use]
638pub fn code_scanning_incompatibility(
639 provider: Option<Provider>,
640 driver: Option<&str>,
641 forge: Option<&str>,
642) -> Option<String> {
643 let named = provider?;
644 let Some(driver) = driver else {
645 return Some(format!(
646 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
647 CODE_SCANNING_TECHS.join(", ")
648 ));
649 };
650 if !CODE_SCANNING_TECHS.contains(&driver) {
651 return Some(format!(
652 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
653 CODE_SCANNING_TECHS.join(", ")
654 ));
655 }
656 let Some(forge) = forge else {
657 return Some(
658 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
659 );
660 };
661 if named == Provider::CodeQl && forge != "github" {
662 return Some(format!(
663 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
664 ));
665 }
666 None
667}
668
669const OSI_APPROVED: [&str; 18] = [
679 "0bsd",
680 "agpl-3.0",
681 "agpl-3.0-only",
682 "agpl-3.0-or-later",
683 "apache-2.0",
684 "bsd-2-clause",
685 "bsd-3-clause",
686 "bsl-1.0",
687 "epl-2.0",
688 "gpl-2.0",
689 "gpl-2.0-only",
690 "gpl-2.0-or-later",
691 "gpl-3.0",
692 "gpl-3.0-only",
693 "gpl-3.0-or-later",
694 "isc",
695 "mit",
696 "mpl-2.0",
697];
698
699const CONTENT_LICENCES: [&str; 4] = ["cc-by-3.0", "cc-by-4.0", "cc-by-sa-3.0", "cc-by-sa-4.0"];
717
718const SPDX_EXCEPTIONS: [&str; 86] = [
735 "389-exception",
736 "asterisk-exception",
737 "asterisk-linking-protocols-exception",
738 "autoconf-exception-2.0",
739 "autoconf-exception-3.0",
740 "autoconf-exception-generic",
741 "autoconf-exception-generic-3.0",
742 "autoconf-exception-macro",
743 "bison-exception-1.24",
744 "bison-exception-2.2",
745 "bootloader-exception",
746 "cgal-linking-exception",
747 "classpath-exception-2.0",
748 "classpath-exception-2.0-short",
749 "clisp-exception-2.0",
750 "cryptsetup-openssl-exception",
751 "digia-qt-lgpl-exception-1.1",
752 "digirule-foss-exception",
753 "ecos-exception-2.0",
754 "erlang-otp-linking-exception",
755 "fawkes-runtime-exception",
756 "fltk-exception",
757 "fmt-exception",
758 "font-exception-2.0",
759 "freertos-exception-2.0",
760 "gcc-exception-2.0",
761 "gcc-exception-2.0-note",
762 "gcc-exception-3.1",
763 "gmsh-exception",
764 "gnat-exception",
765 "gnome-examples-exception",
766 "gnu-compiler-exception",
767 "gnu-javamail-exception",
768 "google-patent-webm",
769 "gpl-3.0-389-ds-base-exception",
770 "gpl-3.0-interface-exception",
771 "gpl-3.0-linking-exception",
772 "gpl-3.0-linking-source-exception",
773 "gpl-cc-1.0",
774 "gstreamer-exception-2005",
775 "gstreamer-exception-2008",
776 "harbour-exception",
777 "i2p-gpl-java-exception",
778 "independent-modules-exception",
779 "kicad-libraries-exception",
780 "kvirc-openssl-exception",
781 "lgpl-3.0-linking-exception",
782 "libpri-openh323-exception",
783 "libtool-exception",
784 "linux-syscall-note",
785 "llgpl",
786 "llvm-exception",
787 "lzma-exception",
788 "mif-exception",
789 "mxml-exception",
790 "nokia-qt-exception-1.1",
791 "ocaml-lgpl-linking-exception",
792 "occt-exception-1.0",
793 "openjdk-assembly-exception-1.0",
794 "openvpn-openssl-exception",
795 "pcre2-exception",
796 "polyparse-exception",
797 "ps-or-pdf-font-exception-20170817",
798 "qpl-1.0-inria-2004-exception",
799 "qt-gpl-exception-1.0",
800 "qt-lgpl-exception-1.1",
801 "qwt-exception-1.0",
802 "romic-exception",
803 "rrdtool-floss-exception-2.0",
804 "rsync-linking-exception",
805 "sane-exception",
806 "shl-2.0",
807 "shl-2.1",
808 "simple-library-usage-exception",
809 "spelling-provider-lgpl-exception",
810 "sqlitestudio-openssl-exception",
811 "stunnel-exception",
812 "swi-exception",
813 "swift-exception",
814 "texinfo-exception",
815 "u-boot-exception-2.0",
816 "ubdl-exception",
817 "universal-foss-exception-1.0",
818 "vsftpd-openssl-exception",
819 "wxwindows-exception-3.1",
820 "x11vnc-openssl-exception",
821];
822
823fn listed(list: &[&str], identifier: &str) -> bool {
830 let lowered = identifier.to_ascii_lowercase();
831 list.contains(&lowered.as_str())
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq)]
836enum Token<'a> {
837 Open,
839 Close,
841 And,
843 Or,
845 With,
848 Identifier(&'a str),
851}
852
853fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
861 let mut tokens = Vec::new();
862 let bytes = expression.as_bytes();
863 let mut at = 0;
864 while at < bytes.len() {
865 let byte = bytes[at];
866 if byte.is_ascii_whitespace() {
867 at += 1;
868 continue;
869 }
870 if byte == b'(' {
871 tokens.push(Token::Open);
872 at += 1;
873 continue;
874 }
875 if byte == b')' {
876 tokens.push(Token::Close);
877 at += 1;
878 continue;
879 }
880 let start = at;
881 while at < bytes.len() {
882 let byte = bytes[at];
883 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
884 at += 1;
885 } else {
886 break;
887 }
888 }
889 if at == start {
890 return None;
891 }
892 let word = &expression[start..at];
893 tokens.push(match word {
894 "AND" => Token::And,
895 "OR" => Token::Or,
896 "WITH" => Token::With,
897 other => Token::Identifier(other),
898 });
899 }
900 Some(tokens)
901}
902
903#[derive(Debug, Clone, Copy)]
910struct Verdict {
911 grants: bool,
913 clean: bool,
915}
916
917struct Spdx<'a> {
926 tokens: &'a [Token<'a>],
927 at: usize,
928}
929
930impl<'a> Spdx<'a> {
931 fn peek(&self) -> Option<Token<'a>> {
933 self.tokens.get(self.at).copied()
934 }
935
936 fn bump(&mut self) -> Option<Token<'a>> {
938 let token = self.peek()?;
939 self.at += 1;
940 Some(token)
941 }
942
943 fn expression(&mut self) -> Option<Verdict> {
953 let mut verdict = self.conjunction()?;
954 while self.peek() == Some(Token::Or) {
955 self.bump();
956 let right = self.conjunction()?;
957 verdict = Verdict {
958 grants: verdict.grants && right.grants,
959 clean: verdict.clean && right.clean,
960 };
961 }
962 Some(verdict)
963 }
964
965 fn conjunction(&mut self) -> Option<Verdict> {
975 let mut verdict = self.operand()?;
976 while self.peek() == Some(Token::And) {
977 self.bump();
978 let right = self.operand()?;
979 verdict = Verdict {
980 grants: verdict.grants || right.grants,
981 clean: verdict.clean && right.clean,
982 };
983 }
984 Some(verdict)
985 }
986
987 fn operand(&mut self) -> Option<Verdict> {
997 match self.bump()? {
998 Token::Open => {
999 let inner = self.expression()?;
1002 (self.bump()? == Token::Close).then_some(inner)
1003 }
1004 Token::Identifier(name) => {
1005 let identifier = name.strip_suffix('+').unwrap_or(name);
1006 let approved = listed(&OSI_APPROVED, identifier);
1007 let verdict = Verdict {
1008 grants: approved,
1009 clean: approved || listed(&CONTENT_LICENCES, identifier),
1010 };
1011 if self.peek() == Some(Token::With) {
1012 self.bump();
1013 match self.bump()? {
1017 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
1018 _ => return None,
1019 }
1020 }
1021 Some(verdict)
1022 }
1023 Token::And | Token::Or | Token::With | Token::Close => None,
1024 }
1025 }
1026}
1027
1028#[must_use]
1044pub fn licence_states_an_open_source_codebase(expression: &str) -> bool {
1045 let Some(tokens) = tokenize(expression) else {
1046 return false;
1047 };
1048 let mut reader = Spdx {
1049 tokens: &tokens,
1050 at: 0,
1051 };
1052 let Some(verdict) = reader.expression() else {
1053 return false;
1054 };
1055 verdict.grants && verdict.clean && reader.at == tokens.len()
1056}
1057
1058#[must_use]
1072pub fn code_scanning_licence_refusal(
1073 provider: Option<Provider>,
1074 driver: Option<&str>,
1075 shape: &CrateShape,
1076) -> Option<String> {
1077 if provider != Some(Provider::CodeQl) {
1078 return None;
1079 }
1080 if driver != Some("rust") {
1081 return Some(format!(
1082 "the {} binding declares no licence field this release reads, and codeql's terms cover an open-source codebase alone; land --code-scanning semgrep, which carries no licence condition",
1083 driver.unwrap_or("release-less")
1084 ));
1085 }
1086 let fallback = "land --code-scanning semgrep, which carries no licence condition";
1087 let Some(text) = shape.cargo_toml.as_deref() else {
1088 return Some(format!(
1089 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1090 ));
1091 };
1092 let Ok(table) = text.parse::<toml::Table>() else {
1093 return Some(format!(
1094 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1095 ));
1096 };
1097 let licence = table
1098 .get("package")
1099 .and_then(toml::Value::as_table)
1100 .and_then(|package| package.get("license"))
1101 .and_then(toml::Value::as_str);
1102 match licence {
1103 None => Some(format!(
1104 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1105 )),
1106 Some(expression) if !licence_states_an_open_source_codebase(expression) => Some(format!(
1107 "the target's license, {expression}, does not state a codebase this release recognizes as open source, and codeql's terms cover an open-source codebase alone; {fallback}"
1108 )),
1109 Some(_) => None,
1110 }
1111}
1112
1113#[must_use]
1116pub fn kind_of(destination: &str) -> Option<Kind> {
1117 if BLOCK_DESTINATIONS.contains(&destination) {
1118 return Some(Kind::Rendered);
1119 }
1120 KINDS
1121 .iter()
1122 .find(|(name, _)| *name == destination)
1123 .map(|(_, kind)| *kind)
1124}
1125
1126pub fn destinations() -> impl Iterator<Item = &'static str> {
1131 KINDS
1132 .iter()
1133 .map(|(name, _)| *name)
1134 .chain(BLOCK_DESTINATIONS)
1135}
1136
1137pub const OWNER_TOKEN: &[u8] = b"OWNER";
1144
1145pub const REPO_PLACEHOLDER: &str = "OWNER";
1150
1151pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1153
1154pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1156
1157pub const IF_RELEASE_TOKEN: &[u8] = b"RK_IF_RELEASE_";
1162
1163pub const IF_NO_RELEASE_TOKEN: &[u8] = b"RK_IF_NO_RELEASE_";
1166
1167pub const SCOPED_TOKEN: &[u8] = b"RK_SCOPED";
1171
1172pub const SCOPE_OPEN_TOKEN: &[u8] = b"RK_SCOPE_OPEN";
1178
1179pub const SCOPE_CLOSE_TOKEN: &[u8] = b"RK_SCOPE_CLOSE";
1181
1182fn release_lines(baseline: &[u8], release: bool) -> Vec<u8> {
1187 if find(baseline, IF_RELEASE_TOKEN).is_none() && find(baseline, IF_NO_RELEASE_TOKEN).is_none() {
1188 return baseline.to_vec();
1189 }
1190 let mut out = Vec::with_capacity(baseline.len());
1191 for line in baseline.split_inclusive(|byte| *byte == b'\n') {
1192 let (token, wanted) = if find(line, IF_NO_RELEASE_TOKEN).is_some() {
1195 (IF_NO_RELEASE_TOKEN, !release)
1196 } else if find(line, IF_RELEASE_TOKEN).is_some() {
1197 (IF_RELEASE_TOKEN, release)
1198 } else {
1199 out.extend_from_slice(line);
1200 continue;
1201 };
1202 if wanted {
1203 out.extend(substitute(line, token, b""));
1204 }
1205 }
1206 out
1207}
1208
1209pub const GITLAB_GATE_FRAGMENT_TOKEN: &[u8] = b"RK_GITLAB_GATE_FRAGMENT";
1212
1213pub const MERGE_FLAG_TOKEN: &[u8] = b"RK_MERGE_FLAG";
1216
1217pub const SQUASH_ON_MERGE_TOKEN: &[u8] = b"RK_SQUASH_ON_MERGE";
1221
1222pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1225
1226pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1230
1231pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1234
1235pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1241
1242pub const PUSH_GUARD_TOKEN: &[u8] = b" # RK_PUSH_GUARD";
1252
1253pub const REQUIRED_CHECK_TOKEN: &[u8] = b"RK_REQUIRED_CHECK";
1255
1256pub const REQUIRED_WORKFLOW_TOKEN: &[u8] = b"RK_REQUIRED_WORKFLOW";
1258
1259pub const BRANCH_SHAPE_TOKEN: &[u8] = b"RK_BRANCH_SHAPE";
1261
1262pub const REQUEST_JOB_TOKEN: &[u8] = b"RK_REQUEST_JOB";
1264
1265pub const RELEASE_GATE_TRIGGER: &str = "blocks/release-gate-trigger.yaml.in";
1267
1268pub const RELEASE_GATE_JOB: &str = "blocks/release-gate-job.yaml.in";
1270
1271#[must_use]
1278pub fn release_driver_shape(driver: Option<&str>) -> Option<(&'static str, &'static str)> {
1279 match driver {
1280 Some("rust") => Some(("release-plz-", "release-plz-pr")),
1283 Some("bash") => Some(("chore/release-v", "release-request")),
1285 Some("python") => Some((
1289 "release-please--branches--RK_TRUNK_BRANCH",
1290 "release-please-pr",
1291 )),
1292 _ => None,
1293 }
1294}
1295
1296#[must_use]
1307pub fn release_gate_spans(driver: Option<&str>) -> [(&'static [u8], &'static [u8]); 3] {
1308 let arm: (&[u8], &[u8]) = if driver == Some("bash") {
1312 (
1313 b"\n # RK_GATE_ARM_BEGIN",
1314 b"\n # RK_GATE_ARM_END",
1315 )
1316 } else {
1317 (b"\n# RK_GATE_ARM_BEGIN", b"\n# RK_GATE_ARM_END")
1318 };
1319 [
1320 (b"\n# RK_GATE_TRIGGER_BEGIN", b"\n# RK_GATE_TRIGGER_END"),
1321 arm,
1322 (b"\n# RK_GATE_JOB_BEGIN", b"\n# RK_GATE_JOB_END"),
1323 ]
1324}
1325
1326#[must_use]
1334fn release_gates(params: &Params) -> bool {
1335 params.forge() == Some("github")
1336 && params.integration() == crate::landing::Integration::Local
1337 && params.release_mode() == ReleaseMode::Automatic
1338 && params.style() == Some(crate::landing::Style::Trunk)
1339 && !params.required_check().is_empty()
1340 && !params.required_workflow().is_empty()
1341 && release_driver_shape(params.driver()).is_some()
1342}
1343
1344fn release_gate_replacements(params: &Params) -> Result<[Option<Vec<u8>>; 3], RkError> {
1356 if !release_gates(params) {
1357 return Ok([None, None, None]);
1358 }
1359 let opened = |block: &str| -> Result<Vec<u8>, RkError> {
1362 let text = embedded_block(block)?;
1363 Ok(substitute_tokens(
1364 format!("\n{}", text.trim_end_matches('\n')).as_bytes(),
1365 params,
1366 ))
1367 };
1368 Ok([
1369 Some(opened(RELEASE_GATE_TRIGGER)?),
1370 Some(Vec::new()),
1373 Some(opened(RELEASE_GATE_JOB)?),
1374 ])
1375}
1376
1377pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1388 (
1389 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1390 b"<!--RK_SECURITY_CONTACT_END-->",
1391 ),
1392 (
1393 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1394 b"<!--RK_SECURITY_RESPONSE_END-->",
1395 ),
1396 (
1397 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1398 b"<!--RK_SECURITY_DEADLINE_END-->",
1399 ),
1400];
1401
1402fn acknowledgment(response: &str) -> String {
1405 format!("Maintainers acknowledge a report within {response}.")
1406}
1407
1408const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1412
1413fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1416 let contact = (!params.security_contact().is_empty())
1417 .then(|| params.security_contact().as_bytes().to_vec());
1418 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1419 [
1420 contact,
1421 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1422 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1423 ]
1424}
1425
1426fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1432 let ordered = find(baseline, begin)
1433 .zip(find(baseline, end))
1434 .filter(|(start, stop)| stop > start);
1435 let Some((start, stop)) = ordered else {
1436 return baseline.to_vec();
1437 };
1438 let mut out = Vec::with_capacity(baseline.len());
1439 out.extend_from_slice(&baseline[..start]);
1440 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1441 out.extend_from_slice(&baseline[stop + end.len()..]);
1442 out
1443}
1444
1445#[must_use]
1463pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1464 let mut out = substitute_tokens(baseline, params);
1465 if let Ok(values) = release_gate_replacements(params) {
1471 for ((begin, end), value) in release_gate_spans(params.driver()).iter().zip(values) {
1472 out = replace_span(&out, begin, end, value.as_deref());
1473 }
1474 }
1475 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1476 out = replace_span(&out, begin, end, value.as_deref());
1477 }
1478 out
1479}
1480
1481#[must_use]
1486fn substitute_tokens(baseline: &[u8], params: &Params) -> Vec<u8> {
1487 let repo = params.repo();
1488 let owner = repo.split('/').next().unwrap_or(repo);
1489 let release = params.release_mode() != ReleaseMode::None;
1493 let mut out = release_lines(baseline, release);
1494 let (scoped, open, close): (&[u8], &[u8], &[u8]) = if release {
1495 (b"scoped ", b"", b"")
1496 } else {
1497 (b"", b"(", b")?")
1498 };
1499 out = substitute(&out, SCOPED_TOKEN, scoped);
1500 out = substitute(&out, SCOPE_OPEN_TOKEN, open);
1501 out = substitute(&out, SCOPE_CLOSE_TOKEN, close);
1502 out = substitute(&out, OWNER_TOKEN, owner.as_bytes());
1503 if let Some(style) = params.style() {
1504 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1505 }
1506 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1507 let (fragment, flag, squash): (&str, &[u8], &[u8]) = match params.merge_form() {
1511 crate::landing::MergeForm::Squash => (GITLAB_TITLE_FRAGMENT, b"--squash", b"true"),
1512 crate::landing::MergeForm::Rebase => (GITLAB_COMMIT_FRAGMENT, b"--rebase", b"false"),
1513 };
1514 out = substitute(&out, GITLAB_GATE_FRAGMENT_TOKEN, fragment.as_bytes());
1515 out = substitute(&out, MERGE_FLAG_TOKEN, flag);
1516 out = substitute(&out, SQUASH_ON_MERGE_TOKEN, squash);
1517 let (shape, job) = release_driver_shape(params.driver()).unwrap_or_default();
1522 out = substitute(&out, BRANCH_SHAPE_TOKEN, shape.as_bytes());
1523 out = substitute(&out, REQUEST_JOB_TOKEN, job.as_bytes());
1524 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1525 let escaped = params.line_prefix().replace('/', "\\/");
1526 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1527 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1528 let required_check = serde_json::Value::String(params.required_check().to_owned()).to_string();
1532 let required_workflow =
1533 serde_json::Value::String(params.required_workflow().to_owned()).to_string();
1534 out = substitute(&out, REQUIRED_CHECK_TOKEN, required_check.as_bytes());
1535 out = substitute(&out, REQUIRED_WORKFLOW_TOKEN, required_workflow.as_bytes());
1536 let guard: &[u8] = if release_gates(params) {
1539 b" && github.event_name == 'push'"
1540 } else {
1541 b""
1542 };
1543 out = substitute(&out, PUSH_GUARD_TOKEN, guard);
1544 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1545 out
1546}
1547
1548#[must_use]
1550pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1551 let mut out = Vec::with_capacity(baseline.len());
1552 let mut rest = baseline;
1553 while let Some(at) = find(rest, token) {
1554 out.extend_from_slice(&rest[..at]);
1555 out.extend_from_slice(value);
1556 rest = &rest[at + token.len()..];
1557 }
1558 out.extend_from_slice(rest);
1559 out
1560}
1561
1562fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1564 haystack
1565 .windows(needle.len())
1566 .position(|window| window == needle)
1567}
1568
1569pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1571
1572pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1574
1575pub const BLOCK_END: &str = "<!-- END release-kit -->";
1577
1578pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1583
1584pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1586
1587pub const BLOCK_DESTINATIONS: [&str; 3] =
1593 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1594
1595pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1597
1598pub const HOOKS_END: &str = "# END release-kit";
1600
1601pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1605
1606pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1608
1609pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1611
1612pub const AGENTS_LINE_AGENT_POLICY: &str = "blocks/agents-line-agent-policy.md.in";
1614
1615pub const AGENTS_LINE_COMMIT_POLICY: &str = "blocks/agents-line-commit-policy.md.in";
1619
1620pub const AGENTS_BLOCK_WORKFLOW: &str = "blocks/agents-block-workflow.md.in";
1623
1624pub const AGENTS_LINE_AGENT_POLICY_WORKFLOW: &str =
1627 "blocks/agents-line-agent-policy-workflow.md.in";
1628
1629pub const AGENTS_LINE_COMMIT_SOURCE_SQUASH: &str = "blocks/agents-line-commit-source-squash.md.in";
1632
1633pub const AGENTS_LINE_COMMIT_SOURCE_REBASE: &str = "blocks/agents-line-commit-source-rebase.md.in";
1636
1637pub const AGENTS_LINE_COMMIT_SCOPE_REQUIRED: &str =
1640 "blocks/agents-line-commit-scope-required.md.in";
1641
1642pub const AGENTS_LINE_COMMIT_SCOPE_PREFERRED: &str =
1644 "blocks/agents-line-commit-scope-preferred.md.in";
1645
1646pub const GLOSSARY_BLOCK_WORKFLOW: &str = "blocks/glossary-workflow.md.in";
1648
1649pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1651
1652pub const PRE_COMMIT_MESSAGE_GUARDS: &str = "blocks/pre-commit-message-guards.yaml.in";
1654
1655pub const PRE_COMMIT_BRANCH_NAME_GUARD: &str = "blocks/pre-commit-branch-name-guard.yaml.in";
1657
1658pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1660
1661pub const PRE_COMMIT_TRUNK_COMMIT_GUARD: &str = "blocks/pre-commit-trunk-commit-guard.yaml.in";
1663
1664pub const PRE_COMMIT_TRUNK_PUSH_GUARD: &str = "blocks/pre-commit-trunk-push-guard.yaml.in";
1666
1667pub const PRE_COMMIT_SWEEP_SKIP: &str = "blocks/pre-commit-sweep-skip.yaml.in";
1669
1670pub const PRE_COMMIT_SWEEP_NONE: &str = "blocks/pre-commit-sweep-none.yaml.in";
1672
1673pub const PRE_COMMIT_INTEGRATE_NOTE: &str = "blocks/pre-commit-integrate-note.yaml.in";
1675
1676pub const PRE_COMMIT_TAG_GUARD: &str = "blocks/pre-commit-tag-guard.yaml.in";
1678
1679pub const PRE_COMMIT_STATUS_RELEASE_FILES: &str = "blocks/pre-commit-status-release-files.yaml.in";
1685
1686pub const PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD: &str =
1689 "blocks/pre-commit-local-trunk-commit-guard.yaml.in";
1690
1691pub const PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS: &str =
1694 "blocks/pre-commit-local-trunk-push-guards.yaml.in";
1695
1696pub const FORCE_SCOPE_ARG: &str = ", --force-scope";
1702
1703pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1710 let name = path.strip_prefix("blocks/").unwrap_or(path);
1711 let file = embedded::BLOCKS
1712 .get_file(name)
1713 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1714 std::str::from_utf8(file.contents())
1715 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1716}
1717
1718#[must_use]
1722pub fn authored(text: &str) -> &str {
1723 text.strip_suffix('\n').unwrap_or(text)
1724}
1725
1726pub const BRANCH_GRAMMAR: &str = r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$";
1734
1735pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1745
1746#[must_use]
1753pub fn scope_is_shaped(scope: &str) -> bool {
1754 !scope.is_empty()
1755 && scope.chars().all(|c| {
1756 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1757 })
1758}
1759
1760pub struct RoutingGuards<'a> {
1762 pub agent_policy: &'a str,
1764 pub commit_policy: &'a str,
1767 pub commit_source: &'a str,
1769 pub commit_scope: &'a str,
1771}
1772
1773#[must_use]
1780pub fn compose_routing(template: &str, guards: Option<&RoutingGuards<'_>>) -> String {
1781 let fragment = |text: &str| format!("{}\n", authored(text));
1782 let (agent, commit) = guards.map_or_else(
1783 || (String::new(), String::new()),
1784 |guards| {
1785 (
1786 fragment(guards.agent_policy),
1787 fragment(
1788 &authored(guards.commit_policy)
1789 .replacen("RK_COMMIT_SOURCE", &fragment(guards.commit_source), 1)
1790 .replacen("RK_COMMIT_SCOPE", &fragment(guards.commit_scope), 1),
1791 ),
1792 )
1793 },
1794 );
1795 authored(template)
1796 .replacen("RK_AGENT_POLICY", &agent, 1)
1797 .replacen("RK_COMMIT_POLICY", &commit, 1)
1798}
1799
1800#[must_use]
1804pub fn compose_glossary(template: &str) -> String {
1805 authored(template).to_owned()
1806}
1807
1808pub struct WorkflowHooks<'a> {
1810 pub message: &'a str,
1812 pub branch_name: &'a str,
1814 pub worktree: Option<&'a str>,
1819}
1820
1821#[must_use]
1841pub fn compose_hooks(
1842 template: &str,
1843 workflow: Option<&WorkflowHooks<'_>>,
1844 parts: &TrunkGuards,
1845) -> String {
1846 let fragment = |text: &str| format!("{}\n", authored(text));
1847 let worktree = workflow
1848 .and_then(|hooks| hooks.worktree)
1849 .map_or_else(String::new, fragment);
1850 let trunk_guards = workflow.is_some();
1851 let mut skips: Vec<&str> = Vec::new();
1852 if trunk_guards {
1853 skips.push("no-commit-to-branch");
1854 }
1855 if !worktree.is_empty() {
1856 skips.push("rk-worktree-location");
1857 }
1858 let sweep = if skips.is_empty() {
1859 fragment(parts.sweep_none)
1860 } else {
1861 fragment(&authored(parts.sweep_skip).replacen("RK_SWEEP_SKIP", &skips.join(","), 1))
1862 };
1863 let (commit_guard, push_guard) = match (trunk_guards, parts.integration) {
1864 (false, _) => (String::new(), String::new()),
1865 (true, Integration::Forge) => (fragment(parts.trunk_commit), fragment(parts.trunk_push)),
1866 (true, Integration::Local) => (
1867 fragment(parts.local_trunk_commit),
1868 fragment(parts.local_trunk_push),
1869 ),
1870 };
1871 let (tag_guard, release_files) = if parts.release {
1872 (fragment(parts.tag_guard), authored(parts.release_files))
1873 } else {
1874 (String::new(), "")
1875 };
1876 let note = match parts.integration {
1877 Integration::Forge => String::new(),
1878 Integration::Local => fragment(parts.integrate_note),
1879 };
1880 let (message, branch_name) = workflow.map_or_else(
1881 || (String::new(), String::new()),
1882 |hooks| {
1883 let message = fragment(hooks.message);
1884 (
1885 if parts.release {
1886 message
1887 } else {
1888 message.replacen(FORCE_SCOPE_ARG, "", 1)
1889 },
1890 fragment(hooks.branch_name),
1891 )
1892 },
1893 );
1894 authored(template)
1895 .replacen("RK_SWEEP_NOTE", &format!("{sweep}{note}"), 1)
1896 .replacen("RK_MESSAGE_GUARDS", &message, 1)
1897 .replacen("RK_TRUNK_COMMIT_GUARD", &commit_guard, 1)
1898 .replacen("RK_BRANCH_NAME_GUARD", &branch_name, 1)
1899 .replacen("RK_WORKTREE_GUARD", &worktree, 1)
1900 .replacen("RK_TRUNK_PUSH_GUARD", &push_guard, 1)
1901 .replacen("RK_TAG_GUARD", &tag_guard, 1)
1902 .replacen("RK_STATUS_RELEASE_FILES", release_files, 1)
1903 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
1904}
1905
1906pub struct TrunkGuards<'a> {
1909 pub integration: Integration,
1911 pub release: bool,
1914 pub trunk_commit: &'a str,
1917 pub trunk_push: &'a str,
1920 pub local_trunk_commit: &'a str,
1923 pub local_trunk_push: &'a str,
1926 pub tag_guard: &'a str,
1928 pub release_files: &'a str,
1931 pub sweep_skip: &'a str,
1933 pub sweep_none: &'a str,
1935 pub integrate_note: &'a str,
1937}
1938
1939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1942pub struct BlockShape {
1943 pub checkout_mode: CheckoutMode,
1945 pub integration: Integration,
1947 pub merge_form: MergeForm,
1949 pub workflow_guards: bool,
1951 pub release: bool,
1953}
1954
1955impl BlockShape {
1956 #[must_use]
1958 pub fn of(params: &Params) -> Self {
1959 Self {
1960 checkout_mode: params.checkout_mode(),
1961 integration: params.integration(),
1962 merge_form: params.merge_form(),
1963 workflow_guards: params.workflow_guards(),
1964 release: params.release_mode() != ReleaseMode::None,
1965 }
1966 }
1967
1968 #[must_use]
1972 pub const fn releasing(
1973 checkout_mode: CheckoutMode,
1974 integration: Integration,
1975 workflow_guards: bool,
1976 ) -> Self {
1977 Self {
1978 checkout_mode,
1979 integration,
1980 merge_form: MergeForm::Squash,
1981 workflow_guards,
1982 release: true,
1983 }
1984 }
1985
1986 const fn routing_sources(self) -> [&'static str; 4] {
1989 let (template, agent) = if self.release {
1990 (AGENTS_BLOCK, AGENTS_LINE_AGENT_POLICY)
1991 } else {
1992 (AGENTS_BLOCK_WORKFLOW, AGENTS_LINE_AGENT_POLICY_WORKFLOW)
1993 };
1994 let source = match self.merge_form {
1995 MergeForm::Squash => AGENTS_LINE_COMMIT_SOURCE_SQUASH,
1996 MergeForm::Rebase => AGENTS_LINE_COMMIT_SOURCE_REBASE,
1997 };
1998 let scope = if self.release {
1999 AGENTS_LINE_COMMIT_SCOPE_REQUIRED
2000 } else {
2001 AGENTS_LINE_COMMIT_SCOPE_PREFERRED
2002 };
2003 [template, agent, source, scope]
2004 }
2005
2006 const fn glossary_source(self) -> &'static str {
2008 if self.release {
2009 GLOSSARY_BLOCK
2010 } else {
2011 GLOSSARY_BLOCK_WORKFLOW
2012 }
2013 }
2014}
2015
2016pub fn routing_block(workflow_guards: bool) -> Result<String, RkError> {
2023 routing_block_of(&BlockShape::releasing(
2024 CheckoutMode::LinkedWorktree,
2025 Integration::Local,
2026 workflow_guards,
2027 ))
2028}
2029
2030pub fn routing_block_of(shape: &BlockShape) -> Result<String, RkError> {
2040 let [template, agent, source, scope] = shape.routing_sources();
2041 let guards = if shape.workflow_guards {
2042 Some(RoutingGuards {
2043 agent_policy: embedded_block(agent)?,
2044 commit_policy: embedded_block(AGENTS_LINE_COMMIT_POLICY)?,
2045 commit_source: embedded_block(source)?,
2046 commit_scope: embedded_block(scope)?,
2047 })
2048 } else {
2049 None
2050 };
2051 Ok(compose_routing(embedded_block(template)?, guards.as_ref()))
2052}
2053
2054pub fn glossary_block() -> Result<String, RkError> {
2061 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
2062}
2063
2064pub fn glossary_block_of(shape: &BlockShape) -> Result<String, RkError> {
2071 Ok(compose_glossary(embedded_block(shape.glossary_source())?))
2072}
2073
2074pub fn hooks_block(
2082 mode: CheckoutMode,
2083 integration: Integration,
2084 workflow_guards: bool,
2085) -> Result<String, RkError> {
2086 hooks_block_of(&BlockShape::releasing(mode, integration, workflow_guards))
2087}
2088
2089pub fn hooks_block_of(shape: &BlockShape) -> Result<String, RkError> {
2095 let workflow = if shape.workflow_guards {
2096 Some(WorkflowHooks {
2097 message: embedded_block(PRE_COMMIT_MESSAGE_GUARDS)?,
2098 branch_name: embedded_block(PRE_COMMIT_BRANCH_NAME_GUARD)?,
2099 worktree: match shape.checkout_mode {
2100 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
2101 CheckoutMode::MainWorktree => None,
2102 },
2103 })
2104 } else {
2105 None
2106 };
2107 let parts = TrunkGuards {
2108 integration: shape.integration,
2109 release: shape.release,
2110 trunk_commit: embedded_block(PRE_COMMIT_TRUNK_COMMIT_GUARD)?,
2111 trunk_push: embedded_block(PRE_COMMIT_TRUNK_PUSH_GUARD)?,
2112 local_trunk_commit: embedded_block(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD)?,
2113 local_trunk_push: embedded_block(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS)?,
2114 tag_guard: embedded_block(PRE_COMMIT_TAG_GUARD)?,
2115 release_files: embedded_block(PRE_COMMIT_STATUS_RELEASE_FILES)?,
2116 sweep_skip: embedded_block(PRE_COMMIT_SWEEP_SKIP)?,
2117 sweep_none: embedded_block(PRE_COMMIT_SWEEP_NONE)?,
2118 integrate_note: embedded_block(PRE_COMMIT_INTEGRATE_NOTE)?,
2119 };
2120 Ok(compose_hooks(
2121 embedded_block(PRE_COMMIT_BLOCK)?,
2122 workflow.as_ref(),
2123 &parts,
2124 ))
2125}
2126
2127type BlockTemplate = (String, Vec<String>, &'static str);
2130
2131fn block_template(destination: &str, params: &Params) -> Result<Option<BlockTemplate>, RkError> {
2134 let shape = BlockShape::of(params);
2135 let guards = shape.workflow_guards;
2136 match destination {
2137 AGENTS_DESTINATION => {
2138 let [template, agent, source, scope] = shape.routing_sources();
2139 let mut sources = vec![template.to_owned()];
2140 if guards {
2141 sources.push(agent.to_owned());
2142 sources.push(AGENTS_LINE_COMMIT_POLICY.to_owned());
2143 sources.push(source.to_owned());
2144 sources.push(scope.to_owned());
2145 }
2146 Ok(Some((routing_block_of(&shape)?, sources, catalog::GUARDS)))
2147 }
2148 GLOSSARY_DESTINATION => Ok(guards
2149 .then(|| -> Result<_, RkError> {
2150 Ok((
2151 glossary_block_of(&shape)?,
2152 vec![shape.glossary_source().to_owned()],
2153 catalog::WORKFLOW_GUARDS,
2154 ))
2155 })
2156 .transpose()?),
2157 HOOKS_DESTINATION => {
2158 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
2159 if guards {
2160 sources.push(PRE_COMMIT_MESSAGE_GUARDS.to_owned());
2161 sources.push(PRE_COMMIT_BRANCH_NAME_GUARD.to_owned());
2162 if shape.checkout_mode == CheckoutMode::LinkedWorktree {
2163 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
2164 }
2165 match shape.integration {
2166 Integration::Forge => {
2167 sources.push(PRE_COMMIT_TRUNK_COMMIT_GUARD.to_owned());
2168 sources.push(PRE_COMMIT_TRUNK_PUSH_GUARD.to_owned());
2169 }
2170 Integration::Local => {
2171 sources.push(PRE_COMMIT_LOCAL_TRUNK_COMMIT_GUARD.to_owned());
2172 sources.push(PRE_COMMIT_LOCAL_TRUNK_PUSH_GUARDS.to_owned());
2173 }
2174 }
2175 }
2176 if shape.release {
2177 sources.push(PRE_COMMIT_TAG_GUARD.to_owned());
2178 sources.push(PRE_COMMIT_STATUS_RELEASE_FILES.to_owned());
2179 }
2180 sources.push(if guards {
2181 PRE_COMMIT_SWEEP_SKIP.to_owned()
2182 } else {
2183 PRE_COMMIT_SWEEP_NONE.to_owned()
2184 });
2185 if shape.integration == Integration::Local {
2186 sources.push(PRE_COMMIT_INTEGRATE_NOTE.to_owned());
2187 }
2188 Ok(Some((hooks_block_of(&shape)?, sources, catalog::GUARDS)))
2189 }
2190 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
2191 }
2192}
2193
2194#[must_use]
2196pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
2197 match destination {
2198 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
2199 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
2200 _ => None,
2201 }
2202}
2203
2204#[must_use]
2207pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
2208 let start = text.find(begin)?;
2209 let stop = text[start..].find(end)? + start + end.len();
2210 Some(&text[start..stop])
2211}
2212
2213#[must_use]
2220pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
2221 let block = block.as_bytes();
2222 let Some(text) = existing else {
2223 return [block, b"\n"].concat();
2224 };
2225 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
2229 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
2230 {
2231 let stop = start + offset + BLOCK_END.len();
2232 return [&text[..start], block, &text[stop..]].concat();
2233 }
2234 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
2238 out.extend_from_slice(text);
2239 if !text.ends_with(b"\n") {
2240 out.push(b'\n');
2241 }
2242 out.push(b'\n');
2243 out.extend_from_slice(block);
2244 out.push(b'\n');
2245 out
2246}
2247
2248pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
2261 let Some(text) = existing else {
2262 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
2263 };
2264 if let Some(defect) = hooks_marker_defect(text) {
2265 return Err(defect);
2266 }
2267 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
2268 return Ok(text.replacen(found, block, 1));
2269 }
2270 let mut out = String::with_capacity(text.len() + block.len() + 1);
2271 let mut placed = false;
2272 for line in text.split_inclusive('\n') {
2273 out.push_str(line);
2274 if !placed && line.trim_end() == "repos:" {
2275 if !out.ends_with('\n') {
2276 out.push('\n');
2277 }
2278 out.push_str(block);
2279 out.push('\n');
2280 placed = true;
2281 }
2282 }
2283 if placed {
2284 Ok(out)
2285 } else {
2286 Err(format!(
2287 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
2288 ))
2289 }
2290}
2291
2292#[must_use]
2302pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
2303 let (begin, end) = block_markers(destination)?;
2304 let begins = text.matches(begin).count();
2305 let ends = text.matches(end).count();
2306 if begins > 1 || ends > 1 {
2307 return Some(format!(
2308 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
2309 ));
2310 }
2311 match (text.find(begin), text.find(end)) {
2312 (Some(begin), Some(end)) if end > begin => None,
2313 (None, None) => None,
2314 _ => Some(format!(
2315 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
2316 )),
2317 }
2318}
2319
2320#[must_use]
2323pub fn hooks_marker_defect(text: &str) -> Option<String> {
2324 marker_defect(HOOKS_DESTINATION, text)
2325}
2326
2327fn propose_document(
2331 destination: &str,
2332 existing: Option<&[u8]>,
2333 region: &[u8],
2334) -> Result<Vec<u8>, String> {
2335 let block = String::from_utf8_lossy(region).into_owned();
2340 if let Some(text) = existing
2341 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
2342 {
2343 return Err(defect);
2344 }
2345 if destination == HOOKS_DESTINATION {
2346 let text = match existing {
2350 None => None,
2351 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
2352 format!(
2353 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
2354 )
2355 })?),
2356 };
2357 return splice_hooks_block(text, &block).map(String::into_bytes);
2358 }
2359 Ok(splice_marked_block(existing, &block))
2360}
2361
2362#[must_use]
2374pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
2375 let Some(text) = shape.cargo_toml.as_deref() else {
2376 return Some(
2377 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
2378 );
2379 };
2380 let Ok(table) = text.parse::<toml::Table>() else {
2381 return Some(
2382 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
2383 );
2384 };
2385 if !table.contains_key("package") {
2386 return Some(
2387 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
2388 );
2389 }
2390 if !shape.cargo_lock {
2391 return Some(
2392 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
2393 );
2394 }
2395 let implicit_bin = shape.main_rs
2396 && table
2397 .get("package")
2398 .and_then(toml::Value::as_table)
2399 .and_then(|package| package.get("autobins"))
2400 .and_then(toml::Value::as_bool)
2401 != Some(false);
2402 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
2403 if explicit_bins.is_none() && !implicit_bin {
2404 return Some(
2405 "the target declares no binary — no effective src/main.rs and no [[bin]] entry — and the seed flake's smoke check runs one; no Nix file lands".to_owned(),
2406 );
2407 }
2408 if let Some(bins) = explicit_bins {
2414 let required = bins
2415 .first()
2416 .and_then(toml::Value::as_table)
2417 .and_then(|bin| bin.get("required-features"))
2418 .and_then(toml::Value::as_array);
2419 if let Some(required) = required {
2420 let enabled = default_features(&table);
2421 let missing = required
2422 .iter()
2423 .filter_map(toml::Value::as_str)
2424 .any(|feature| !enabled.contains(feature));
2425 if missing {
2426 return Some(
2427 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
2428 );
2429 }
2430 }
2431 }
2432 None
2433}
2434
2435fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
2438 let edge = format!("dep:{name}");
2439 features.values().any(|list| {
2440 list.as_array().is_some_and(|entries| {
2441 entries
2442 .iter()
2443 .filter_map(toml::Value::as_str)
2444 .any(|entry| entry == edge)
2445 })
2446 })
2447}
2448
2449fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
2452 ["dependencies", "build-dependencies"]
2453 .iter()
2454 .any(|section| {
2455 table
2456 .get(*section)
2457 .and_then(toml::Value::as_table)
2458 .and_then(|dependencies| dependencies.get(name))
2459 .and_then(toml::Value::as_table)
2460 .and_then(|dependency| dependency.get("optional"))
2461 .and_then(toml::Value::as_bool)
2462 == Some(true)
2463 })
2464}
2465
2466fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
2473 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
2474 return std::collections::BTreeSet::new();
2475 };
2476 let mut enabled = std::collections::BTreeSet::new();
2477 let mut queue = vec!["default".to_owned()];
2478 while let Some(name) = queue.pop() {
2479 if !enabled.insert(name.clone()) {
2480 continue;
2481 }
2482 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
2483 for implied in implies.iter().filter_map(toml::Value::as_str) {
2484 if implied.starts_with("dep:") || implied.contains("?/") {
2485 continue;
2489 }
2490 if let Some((package, _)) = implied.split_once('/') {
2491 let feature_exists =
2499 features.contains_key(package) || !dep_edge_suppresses(features, package);
2500 if is_optional_dependency(table, package) && feature_exists {
2501 queue.push(package.to_owned());
2502 }
2503 } else {
2504 queue.push(implied.to_owned());
2505 }
2506 }
2507 }
2508 }
2509 enabled
2510}
2511
2512#[must_use]
2520pub fn flake_pair_withheld(
2521 flake_recorded: bool,
2522 flake_nix_present: bool,
2523 flake_lock_present: bool,
2524) -> Option<String> {
2525 if flake_recorded {
2526 return None;
2527 }
2528 let present: Vec<&str> = [
2529 ("flake.nix", flake_nix_present),
2530 ("flake.lock", flake_lock_present),
2531 ]
2532 .into_iter()
2533 .filter_map(|(name, present)| present.then_some(name))
2534 .collect();
2535 if present.is_empty() {
2536 return None;
2537 }
2538 Some(format!(
2539 "the target already carries {}; its flake pair stays its own",
2540 present.join(" and ")
2541 ))
2542}
2543
2544#[must_use]
2554pub fn nix_withholding(
2555 nix: bool,
2556 evidence: &TargetEvidence,
2557) -> Option<(&'static [&'static str], String)> {
2558 if !nix {
2559 return None;
2560 }
2561 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
2562 return Some((&NIX_DESTINATIONS[..], reason));
2563 }
2564 flake_pair_withheld(
2565 evidence.flake_recorded,
2566 evidence.flake_nix_present,
2567 evidence.flake_lock_present,
2568 )
2569 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
2570}
2571
2572#[cfg(test)]
2573mod tests {
2574 use super::{
2575 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
2576 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
2577 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
2578 };
2579 use crate::landing::{Params, Style};
2580
2581 fn supported_shape() -> CrateShape {
2583 CrateShape {
2584 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
2585 cargo_lock: true,
2586 main_rs: true,
2587 }
2588 }
2589
2590 fn input(evidence: TargetEvidence) -> ProjectionInput {
2591 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2592 params.set_nix_for_test(true);
2593 ProjectionInput { params, evidence }
2594 }
2595
2596 fn compute(evidence: TargetEvidence) -> Projection {
2597 Projection::compute(&input(evidence)).expect("the embedded pair projects")
2598 }
2599
2600 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
2601 projection
2602 .candidates
2603 .iter()
2604 .find(|candidate| candidate.destination == destination)
2605 .expect("the destination projects")
2606 }
2607
2608 #[test]
2613 fn the_workflow_guards_land_only_where_requested() {
2614 let mut plain = input(TargetEvidence {
2615 crate_shape: supported_shape(),
2616 ..TargetEvidence::default()
2617 });
2618 plain.params.set_workflow_guards_for_test(false);
2619 let plain = Projection::compute(&plain).expect("the embedded pair projects");
2620 assert!(
2621 !plain
2622 .candidates
2623 .iter()
2624 .any(|candidate| candidate.destination == GLOSSARY_DESTINATION),
2625 "a target without the guards takes no glossary"
2626 );
2627 let agents =
2628 String::from_utf8(candidate(&plain, AGENTS_DESTINATION).bytes.clone()).expect("text");
2629 assert!(!agents.contains("guides and never drives"), "{agents}");
2630 let hooks =
2631 String::from_utf8(candidate(&plain, HOOKS_DESTINATION).bytes.clone()).expect("text");
2632 assert!(!hooks.contains("conventional-pre-commit"), "{hooks}");
2633 assert!(hooks.contains("rk-no-hand-authored-tag"), "{hooks}");
2634
2635 let guarded = compute(TargetEvidence {
2636 crate_shape: supported_shape(),
2637 ..TargetEvidence::default()
2638 });
2639 let glossary = candidate(&guarded, GLOSSARY_DESTINATION);
2640 assert_eq!(
2641 glossary.capability,
2642 crate::profile::catalog::WORKFLOW_GUARDS
2643 );
2644 assert_eq!(
2645 candidate(&guarded, AGENTS_DESTINATION).capability,
2646 crate::profile::catalog::GUARDS
2647 );
2648 let hooks =
2649 String::from_utf8(candidate(&guarded, HOOKS_DESTINATION).bytes.clone()).expect("text");
2650 for hook in ["conventional-pre-commit", "rk-message", "rk-branch-name"] {
2651 assert!(hooks.contains(hook), "{hook}: {hooks}");
2652 }
2653 }
2654
2655 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
2657 let text = String::from_utf8_lossy(bytes);
2658 let start = text.find(begin).expect("the begin marker is present");
2659 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
2660 (bytes[..start].to_vec(), bytes[stop..].to_vec())
2661 }
2662
2663 #[test]
2664 fn equal_projection_inputs_yield_byte_identical_projections() {
2665 let mut documents = std::collections::BTreeMap::new();
2666 documents.insert(
2667 AGENTS_DESTINATION.to_owned(),
2668 b"# Widget\n\nOwn rules.\n".to_vec(),
2669 );
2670 let evidence = TargetEvidence {
2671 documents,
2672 crate_shape: supported_shape(),
2673 ..TargetEvidence::default()
2674 };
2675 let first = input(evidence.clone());
2676 let second = input(evidence);
2677 assert_eq!(first, second, "the inputs are values and compare equal");
2678 let a = Projection::compute(&first).expect("the pair projects");
2679 let b = Projection::compute(&second).expect("the pair projects");
2680 assert_eq!(a.candidates.len(), b.candidates.len());
2681 for (x, y) in a.candidates.iter().zip(&b.candidates) {
2682 assert_eq!(x.destination, y.destination);
2683 assert_eq!(x.kind, y.kind);
2684 assert_eq!(x.placement, y.placement);
2685 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
2686 assert_eq!(x.region, y.region, "{}", x.destination);
2687 assert_eq!(x.sources, y.sources, "{}", x.destination);
2688 }
2689 assert_eq!(a, b);
2690 let destinations: Vec<&str> = a
2691 .candidates
2692 .iter()
2693 .map(|candidate| candidate.destination.as_str())
2694 .collect();
2695 let mut sorted = destinations.clone();
2696 sorted.sort_unstable();
2697 assert_eq!(destinations, sorted, "candidates sort by destination");
2698 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
2699 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
2700 }
2701
2702 #[test]
2706 fn the_scorecard_destination_projects_only_under_the_opt_in() {
2707 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
2708 let destination = SCORECARD_DESTINATIONS[0];
2709 assert_eq!(kind_of(destination), Some(Kind::Rendered));
2710
2711 let project = |scorecard: bool| {
2712 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2713 params.set_scorecard_for_test(scorecard);
2714 Projection::compute(&ProjectionInput {
2715 params,
2716 evidence: TargetEvidence::default(),
2717 })
2718 .expect("the embedded pair projects")
2719 };
2720
2721 let off = project(false);
2722 assert!(
2723 !off.candidates
2724 .iter()
2725 .any(|candidate| candidate.destination == destination),
2726 "off by default, and an absent candidate is no omission"
2727 );
2728 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
2729
2730 let on = project(true);
2731 let candidate = candidate(&on, destination);
2732 assert_eq!(candidate.kind, Kind::Rendered);
2733 assert_eq!(candidate.placement, Placement::Whole);
2734 let text = String::from_utf8_lossy(&candidate.bytes);
2735 assert!(!text.contains("RK_"), "a token survived: {text}");
2736 }
2737
2738 #[test]
2743 fn the_licence_judgment_reads_what_every_reader_obtains() {
2744 use super::licence_states_an_open_source_codebase as approved;
2745 for expression in [
2746 "MIT",
2747 "Apache-2.0",
2748 "MIT OR Apache-2.0",
2749 "MIT AND Apache-2.0",
2750 "(MIT OR Apache-2.0) AND ISC",
2751 "Apache-2.0 WITH LLVM-exception OR MIT",
2752 "GPL-3.0+",
2753 ] {
2754 assert!(approved(expression), "{expression} is OSI-approved");
2755 }
2756 for expression in [
2761 "MIT AND CC-BY-4.0",
2762 "CC-BY-4.0 AND MIT",
2763 "MIT AND (CC-BY-4.0 OR CC-BY-SA-4.0)",
2764 "(MIT OR CC-BY-4.0) AND MIT",
2765 "MIT OR Apache-2.0 AND CC-BY-4.0",
2766 "Apache-2.0 WITH LLVM-exception AND CC-BY-SA-3.0",
2767 ] {
2768 assert!(approved(expression), "{expression} states open source");
2769 }
2770 for expression in [
2771 "",
2772 " ",
2773 "LicenseRef-proprietary",
2774 "MIT AND LicenseRef-proprietary",
2775 "SEE LICENSE IN COPYING",
2776 "CC-BY-4.0",
2777 "DocumentRef-spdx:LicenseRef-proprietary",
2778 ] {
2779 assert!(!approved(expression), "{expression} is not recognized");
2780 }
2781 for expression in [
2786 "MIT OR CC-BY-4.0",
2787 "CC-BY-4.0 AND CC-BY-SA-4.0",
2788 "CC-BY-4.0 AND LicenseRef-proprietary",
2789 "CC0-1.0",
2790 "MIT AND CC-BY-4.0 AND LicenseRef-proprietary",
2791 ] {
2792 assert!(!approved(expression), "{expression} grants no code terms");
2793 }
2794 assert!(
2799 !approved("CC-BY-4.0 OR CC-BY-SA-4.0 AND MIT"),
2800 "AND binds tighter than OR"
2801 );
2802 }
2803
2804 #[test]
2810 fn the_licence_judgment_holds_the_expression_to_the_spdx_grammar() {
2811 use super::licence_states_an_open_source_codebase as approved;
2812 for expression in [
2817 "MIT OR",
2818 "OR MIT",
2819 "MIT AND",
2820 "MIT WITH",
2821 "WITH LLVM-exception",
2822 "(MIT",
2823 "MIT)",
2824 "MIT Apache-2.0",
2825 "()",
2826 "(MIT OR Apache-2.0",
2827 "MIT OR (Apache-2.0",
2828 "MIT OR ()",
2829 "AND",
2830 "(",
2831 ")",
2832 "MIT WITH AND ISC",
2833 "MIT OR OR ISC",
2834 "MIT @ Apache-2.0",
2835 ] {
2836 assert!(!approved(expression), "{expression} is malformed");
2837 }
2838 for expression in [
2840 "MIT AND (Apache-2.0 OR ISC)",
2841 "((MIT))",
2842 "Apache-2.0 WITH LLVM-exception AND ISC",
2843 "(Apache-2.0 WITH LLVM-exception)",
2844 ] {
2845 assert!(approved(expression), "{expression} is well formed");
2846 }
2847 for expression in [
2852 "mit",
2853 "MiT",
2854 "apache-2.0 OR mit",
2855 "APACHE-2.0 WITH llvm-exception",
2856 ] {
2857 assert!(
2858 approved(expression),
2859 "{expression} names an approved licence"
2860 );
2861 }
2862 for expression in [
2866 "MIT or Apache-2.0",
2867 "MIT and Apache-2.0",
2868 "MIT with LLVM-exception",
2869 ] {
2870 assert!(!approved(expression), "{expression} carries no operator");
2871 }
2872 for expression in [
2876 "MIT WITH definitely-not-an-spdx-exception",
2877 "MIT WITH MIT",
2878 "MIT WITH Apache-2.0",
2879 ] {
2880 assert!(!approved(expression), "{expression} names no exception");
2881 }
2882 for expression in [
2886 "GPL-2.0-only WITH GCC-exception-2.0",
2887 "GPL-2.0-or-later WITH Classpath-exception-2.0",
2888 "Apache-2.0 WITH Swift-exception",
2889 "GPL-3.0-only WITH Autoconf-exception-generic",
2890 ] {
2891 assert!(approved(expression), "{expression} names a real exception");
2892 }
2893 }
2894
2895 #[test]
2901 fn a_recorded_provider_its_pair_cannot_run_is_named() {
2902 use super::{Provider, code_scanning_incompatibility};
2903
2904 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
2905 assert!(
2906 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
2907 .is_none()
2908 );
2909 assert!(
2910 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
2911 .is_none()
2912 );
2913
2914 let bash =
2915 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
2916 .expect("a binding with no scanner is named");
2917 assert!(bash.contains("bash binding"), "{bash}");
2918 let gitlab =
2919 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
2920 .expect("codeql on gitlab is named");
2921 assert!(gitlab.contains("codeql"), "{gitlab}");
2922 let release_less =
2923 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
2924 .expect("no driver is named");
2925 assert!(
2926 release_less.contains("no automatic release driver"),
2927 "{release_less}"
2928 );
2929
2930 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2933 params.set_code_scanning_for_test(Some(Provider::Semgrep));
2934 let clean = Projection::compute(&ProjectionInput {
2935 params: params.clone(),
2936 evidence: TargetEvidence::default(),
2937 })
2938 .expect("the pair projects");
2939 assert!(
2940 clean.record_defects.is_empty(),
2941 "{:?}",
2942 clean.record_defects
2943 );
2944 }
2945
2946 #[test]
2950 fn the_code_scanning_destinations_follow_the_recorded_provider() {
2951 use super::{
2952 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
2953 };
2954 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2955 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
2956 }
2957
2958 let project = |provider: Option<Provider>| {
2959 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2960 params.set_code_scanning_for_test(provider);
2961 Projection::compute(&ProjectionInput {
2962 params,
2963 evidence: TargetEvidence {
2964 crate_shape: CrateShape {
2965 cargo_toml: Some(
2966 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
2967 ),
2968 ..CrateShape::default()
2969 },
2970 ..TargetEvidence::default()
2971 },
2972 })
2973 .expect("the embedded pair projects")
2974 };
2975 let landed = |projection: &Projection, destination: &str| {
2976 projection
2977 .candidates
2978 .iter()
2979 .any(|candidate| candidate.destination == destination)
2980 };
2981
2982 let off = project(None);
2983 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2984 assert!(!landed(&off, destination), "{destination}");
2985 }
2986 assert!(off.licence_refusal.is_none());
2987
2988 let codeql = project(Some(Provider::CodeQl));
2989 assert!(landed(
2990 &codeql,
2991 ".github/workflows/code-scanning-codeql.yml"
2992 ));
2993 assert!(!landed(
2994 &codeql,
2995 ".github/workflows/code-scanning-semgrep.yml"
2996 ));
2997 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
2998
2999 let semgrep = project(Some(Provider::Semgrep));
3000 assert!(landed(
3001 &semgrep,
3002 ".github/workflows/code-scanning-semgrep.yml"
3003 ));
3004 assert!(!landed(
3005 &semgrep,
3006 ".github/workflows/code-scanning-codeql.yml"
3007 ));
3008
3009 let proprietary = CrateShape {
3011 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
3012 ..CrateShape::default()
3013 };
3014 assert!(
3015 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
3016 .is_none()
3017 );
3018 let refusal =
3019 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
3020 .expect("codeql refuses a licence its terms do not cover");
3021 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
3022 assert!(refusal.contains("semgrep"), "{refusal}");
3023 let bash =
3026 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
3027 .expect("an unread binding refuses");
3028 assert!(bash.contains("bash binding"), "{bash}");
3029 }
3030
3031 #[test]
3037 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
3038 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
3039 let text = std::fs::read_to_string(&path).expect("the source reads");
3040 let production = text.split("#[cfg(test)]").next().unwrap_or("");
3041 let needles = [
3042 "std::fs",
3043 "std::env",
3044 "std::process",
3045 "std::time",
3046 "SystemTime",
3047 "Instant",
3048 "std::net",
3049 "Command::new",
3050 "registry::",
3051 "curl",
3052 "reqwest",
3053 "blob(",
3054 ];
3055 let mut hits = Vec::new();
3056 for (index, line) in production.lines().enumerate() {
3057 if line.trim_start().starts_with("//") {
3058 continue;
3059 }
3060 for needle in needles {
3061 if line.contains(needle) {
3062 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
3063 }
3064 }
3065 }
3066 assert!(
3067 hits.is_empty(),
3068 "the projection reads beyond its inputs: {hits:?}"
3069 );
3070 }
3071
3072 #[test]
3073 #[allow(
3074 clippy::too_many_lines,
3075 reason = "one test walks the three marked destinations and the three unmarked shapes"
3076 )]
3077 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
3078 let agents_before = "# Widget\n\nOperator prose above.\n\n";
3079 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
3080 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
3081 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
3082 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
3083 let hooks_after =
3084 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3085 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
3086 let mut documents = std::collections::BTreeMap::new();
3087 documents.insert(
3088 AGENTS_DESTINATION.to_owned(),
3089 format!(
3090 "{agents_before}{}{agents_after}",
3091 stale(BLOCK_BEGIN, BLOCK_END)
3092 )
3093 .into_bytes(),
3094 );
3095 documents.insert(
3096 GLOSSARY_DESTINATION.to_owned(),
3097 format!(
3098 "{glossary_before}{}{glossary_after}",
3099 stale(BLOCK_BEGIN, BLOCK_END)
3100 )
3101 .into_bytes(),
3102 );
3103 documents.insert(
3104 HOOKS_DESTINATION.to_owned(),
3105 format!(
3106 "{hooks_before}{}{hooks_after}",
3107 stale(HOOKS_BEGIN, HOOKS_END)
3108 )
3109 .into_bytes(),
3110 );
3111 let projection = compute(TargetEvidence {
3112 documents: documents.clone(),
3113 crate_shape: supported_shape(),
3114 ..TargetEvidence::default()
3115 });
3116 assert!(
3117 projection.collisions.is_empty(),
3118 "{:?}",
3119 projection.collisions
3120 );
3121 for (destination, before, after) in [
3122 (AGENTS_DESTINATION, agents_before, agents_after),
3123 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
3124 (HOOKS_DESTINATION, hooks_before, hooks_after),
3125 ] {
3126 let candidate = candidate(&projection, destination);
3127 let Placement::Region { begin, end } = candidate.placement else {
3128 panic!("{destination} is a region");
3129 };
3130 let region = candidate
3131 .region
3132 .as_deref()
3133 .expect("a region carries its block");
3134 let (head, tail) = outside(&candidate.bytes, begin, end);
3135 assert_eq!(
3136 head,
3137 before.as_bytes(),
3138 "{destination}: bytes before the markers"
3139 );
3140 assert_eq!(
3141 tail,
3142 after.as_bytes(),
3143 "{destination}: bytes after the markers"
3144 );
3145 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
3146 assert_eq!(
3147 inside, region,
3148 "{destination}: the region is the rendered block"
3149 );
3150 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
3151 assert_eq!(head, existing_head);
3152 assert_eq!(tail, existing_tail);
3153 }
3154
3155 let own_hooks =
3159 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
3160 let own_agents = "# Widget\n\nOwn rules.";
3161 let mut documents = std::collections::BTreeMap::new();
3162 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
3163 documents.insert(
3164 AGENTS_DESTINATION.to_owned(),
3165 own_agents.as_bytes().to_vec(),
3166 );
3167 let projection = compute(TargetEvidence {
3168 documents,
3169 crate_shape: supported_shape(),
3170 ..TargetEvidence::default()
3171 });
3172 assert!(
3173 projection.collisions.is_empty(),
3174 "{:?}",
3175 projection.collisions
3176 );
3177 let hooks = candidate(&projection, HOOKS_DESTINATION);
3178 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
3179 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
3180 assert!(
3181 hooks_text.starts_with(&format!(
3182 "repos:\n{region}\n - repo: https://example.com/own"
3183 )),
3184 "{hooks_text}"
3185 );
3186 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
3187 let agents = candidate(&projection, AGENTS_DESTINATION);
3188 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
3189 assert_eq!(
3190 extract_block(
3191 &String::from_utf8_lossy(&agents.bytes),
3192 BLOCK_BEGIN,
3193 BLOCK_END
3194 )
3195 .map(str::as_bytes),
3196 agents.region.as_deref()
3197 );
3198 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
3199 let region = glossary.region.as_deref().expect("a region");
3200 assert_eq!(
3201 glossary.bytes,
3202 [region, b"\n"].concat(),
3203 "an absent file is fresh"
3204 );
3205 }
3206
3207 #[test]
3213 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
3214 let mut documents = std::collections::BTreeMap::new();
3215 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
3216 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
3217 invalid.extend_from_slice(b" - repo: local \xff below\n");
3218 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
3219 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
3220 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
3221 agents.extend_from_slice(b"r\xe9sum\xe9\n");
3222 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
3223 let projection = compute(TargetEvidence {
3224 documents,
3225 crate_shape: supported_shape(),
3226 ..TargetEvidence::default()
3227 });
3228 let collided: Vec<&str> = projection
3229 .collisions
3230 .iter()
3231 .map(|c| c.destination.as_str())
3232 .collect();
3233 assert_eq!(collided, [HOOKS_DESTINATION]);
3234 assert!(
3235 projection.collisions[0].reason.contains("not UTF-8"),
3236 "{}",
3237 projection.collisions[0].reason
3238 );
3239 assert!(
3240 !projection
3241 .candidates
3242 .iter()
3243 .any(|c| c.destination == HOOKS_DESTINATION),
3244 "a colliding destination projects no candidate"
3245 );
3246 let agents = candidate(&projection, AGENTS_DESTINATION);
3247 assert!(
3248 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
3249 "{:?}",
3250 agents.bytes
3251 );
3252 assert!(
3253 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
3254 "{:?}",
3255 agents.bytes
3256 );
3257 assert!(
3258 !agents.bytes.contains(&0xEF),
3259 "a replacement character landed"
3260 );
3261
3262 let mut documents = std::collections::BTreeMap::new();
3263 let valid =
3264 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
3265 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
3266 let projection = compute(TargetEvidence {
3267 documents,
3268 crate_shape: supported_shape(),
3269 ..TargetEvidence::default()
3270 });
3271 assert!(
3272 projection.collisions.is_empty(),
3273 "{:?}",
3274 projection.collisions
3275 );
3276 let hooks = candidate(&projection, HOOKS_DESTINATION);
3277 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
3278 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
3279 assert!(text.ends_with("\n - repo: local\n"), "{text}");
3280 assert!(!text.contains("stale"), "the region is replaced: {text}");
3281 }
3282
3283 #[test]
3287 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
3288 let files: Vec<(String, &[u8])> = vec![
3289 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
3290 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
3291 ];
3292 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3293 .expect_err("a whole file at a block destination refuses");
3294 let text = err.to_string();
3295 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
3296 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
3297 assert!(text.contains(super::AGENTS_LINE_AGENT_POLICY), "{text}");
3298 assert!(text.contains("embedded sources are defective"), "{text}");
3299 }
3300
3301 #[test]
3302 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
3303 {
3304 let files: Vec<(String, &[u8])> = vec![
3307 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3308 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
3309 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3310 ];
3311 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
3312 .expect_err("a doubled destination refuses");
3313 let text = err.to_string();
3314 assert!(
3315 text.contains("snippets/_shared/github/SECURITY.md"),
3316 "{text}"
3317 );
3318 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
3319 assert!(text.contains("embedded sources are defective"), "{text}");
3320
3321 let clean: Vec<(String, &[u8])> = vec![
3322 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
3323 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
3324 ];
3325 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
3326 .expect("a clean list projects");
3327 let whole: Vec<&str> = projection
3328 .candidates
3329 .iter()
3330 .filter(|c| c.placement == Placement::Whole)
3331 .map(|c| c.destination.as_str())
3332 .collect();
3333 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
3334 assert_eq!(
3335 projection
3336 .candidates
3337 .iter()
3338 .find(|c| c.destination == "SECURITY.md")
3339 .map(|c| c.sources.clone()),
3340 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
3341 );
3342
3343 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
3344 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
3345 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
3346 let mut documents = std::collections::BTreeMap::new();
3347 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
3348 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
3349 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
3350 let projection = compute(TargetEvidence {
3351 documents,
3352 crate_shape: supported_shape(),
3353 ..TargetEvidence::default()
3354 });
3355 let mut collided: Vec<&str> = projection
3356 .collisions
3357 .iter()
3358 .map(|Collision { destination, .. }| destination.as_str())
3359 .collect();
3360 collided.sort_unstable();
3361 let mut expected = BLOCK_DESTINATIONS.to_vec();
3362 expected.sort_unstable();
3363 assert_eq!(collided, expected);
3364 for collision in &projection.collisions {
3365 assert!(
3366 collision.reason.contains(&collision.destination),
3367 "{collision:?}"
3368 );
3369 assert!(
3370 !projection
3371 .candidates
3372 .iter()
3373 .any(|candidate| candidate.destination == collision.destination),
3374 "{} collided and still projects",
3375 collision.destination
3376 );
3377 }
3378 let agents = projection
3379 .collisions
3380 .iter()
3381 .find(|c| c.destination == AGENTS_DESTINATION)
3382 .expect("the doubled document collides");
3383 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
3384 let hooks = projection
3385 .collisions
3386 .iter()
3387 .find(|c| c.destination == HOOKS_DESTINATION)
3388 .expect("the unmatched document collides");
3389 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
3390 }
3391}