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, 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 (template, sources) = block_template(destination, params.checkout_mode())?;
286 if let Some(whole) = candidates
287 .iter()
288 .find(|candidate| candidate.destination == destination)
289 {
290 return Err(anyhow::anyhow!(
291 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
292 whole.sources.join(", "),
293 sources.join(", ")
294 )
295 .into());
296 }
297 let region = render(template.as_bytes(), params);
298 let (begin, end) = block_markers(destination).ok_or_else(|| {
299 anyhow::anyhow!("{destination} is a block destination with no markers")
300 })?;
301 match propose_document(destination, evidence.document(destination), ®ion) {
302 Ok(bytes) => candidates.push(Candidate {
303 capability: catalog::GUARDS,
304 destination: destination.to_owned(),
305 kind: Kind::Rendered,
306 placement: Placement::Region { begin, end },
307 bytes,
308 region: Some(region),
309 sources,
310 }),
311 Err(reason) => collisions.push(Collision {
312 destination: destination.to_owned(),
313 reason,
314 }),
315 }
316 }
317 let mut omissions = Vec::new();
318 if let Some((set, reason)) = nix_withholding(params.nix_packaging(), evidence)
319 && candidates
320 .iter()
321 .any(|candidate| candidate.capability == catalog::PACKAGING_NIX)
322 {
323 candidates.retain(|candidate| {
324 if set.contains(&candidate.destination.as_str()) {
325 omissions.push(Omission {
326 destination: candidate.destination.clone(),
327 reason: reason.clone(),
328 action: None,
329 });
330 false
331 } else {
332 true
333 }
334 });
335 withhold(&mut capabilities, catalog::PACKAGING_NIX, &reason, None);
336 }
337 let title_root = candidates.iter().position(|candidate| {
343 candidate.capability == catalog::TITLE_CHECK
344 && candidate.destination == GITLAB_ROOT_PIPELINE
345 });
346 if let Some(index) = title_root
347 && evidence.root_pipeline_present
348 && !evidence.root_pipeline_recorded
349 {
350 let candidate = candidates.remove(index);
351 let reason = format!(
352 "the target owns {GITLAB_ROOT_PIPELINE}, so the release-less root pipeline that would activate the title gate stays out; the fragment lands as the gate's prerequisite"
353 );
354 let action = format!(
355 "add the include to the target's own {GITLAB_ROOT_PIPELINE}: include:\n - local: {GITLAB_TITLE_FRAGMENT}"
356 );
357 omissions.push(Omission {
358 destination: candidate.destination,
359 reason: reason.clone(),
360 action: Some(action.clone()),
361 });
362 withhold(
363 &mut capabilities,
364 catalog::TITLE_CHECK,
365 &reason,
366 Some(action),
367 );
368 }
369 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
370 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
371 let scanning_selected = capabilities.iter().any(|selection| {
377 selection.id == catalog::CODE_SCANNING && selection.status == Status::Selected
378 });
379 let licence_refusal = scanning_selected
380 .then(|| {
381 code_scanning_licence_refusal(
382 params.code_scanning(),
383 params.driver(),
384 &evidence.crate_shape,
385 )
386 })
387 .flatten();
388 let mut record_defects: Vec<String> = Vec::new();
393 if params.release_mode() == ReleaseMode::Automatic
397 && let Some(selection) = capabilities
398 .iter()
399 .find(|selection| selection.id == catalog::RELEASE_AUTOMATION)
400 && matches!(selection.status, Status::Unavailable | Status::Unknown)
401 && let Some(reason) = &selection.reason
402 {
403 record_defects.push(reason.clone());
404 }
405 Ok(Self {
406 capabilities,
407 candidates,
408 omissions,
409 collisions,
410 licence_refusal,
411 record_defects,
412 })
413 }
414
415 #[must_use]
419 pub fn release_unavailable(&self) -> Option<&str> {
420 self.capabilities
421 .iter()
422 .find(|selection| {
423 selection.id == catalog::RELEASE_AUTOMATION
424 && matches!(selection.status, Status::Unavailable | Status::Unknown)
425 })
426 .and_then(|selection| selection.reason.as_deref())
427 }
428
429 #[must_use]
431 pub fn capability(&self, id: &str) -> Option<&Selection> {
432 self.capabilities
433 .iter()
434 .find(|selection| selection.id == id)
435 }
436}
437
438fn withhold(capabilities: &mut [Selection], id: &str, reason: &str, action: Option<String>) {
440 if let Some(selection) = capabilities.iter_mut().find(|selection| selection.id == id) {
441 selection.status = Status::Withheld;
442 selection.reason = Some(reason.to_owned());
443 selection.action = action;
444 }
445}
446
447pub const GITLAB_ROOT_PIPELINE: &str = ".gitlab-ci.yml";
450
451pub const GITLAB_TITLE_FRAGMENT: &str = ".gitlab/ci/mr-title.yml";
453
454fn embedded_snippets() -> Vec<(String, &'static [u8])> {
457 embedded::walk(&embedded::SNIPPETS)
458 .into_iter()
459 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
460 .collect()
461}
462
463#[must_use]
466pub fn supported_pairs() -> Vec<(String, String)> {
467 Availability::embedded()
468 .automation_tuples()
469 .into_iter()
470 .filter_map(|entry| {
471 entry
472 .split_once(", ")
473 .map(|(driver, forge)| (driver.to_owned(), forge.to_owned()))
474 })
475 .collect()
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "lowercase")]
481pub enum Kind {
482 Rendered,
485 Seeded,
488 State,
491}
492
493impl Kind {
494 #[must_use]
496 pub const fn as_str(self) -> &'static str {
497 match self {
498 Self::Rendered => "rendered",
499 Self::Seeded => "seeded",
500 Self::State => "state",
501 }
502 }
503}
504
505const KINDS: [(&str, Kind); 20] = [
511 (".github/workflows/release-plz.yml", Kind::Rendered),
512 (".github/workflows/release-please.yml", Kind::Rendered),
513 (".github/workflows/release.yml", Kind::Rendered),
514 (".github/workflows/pr-title.yml", Kind::Rendered),
515 (".github/workflows/scorecard.yml", Kind::Rendered),
516 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
517 (
518 ".github/workflows/code-scanning-semgrep.yml",
519 Kind::Rendered,
520 ),
521 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
522 (".gitlab-ci.yml", Kind::Rendered),
523 ("SECURITY.md", Kind::Rendered),
524 (".gitlab/ci/mr-title.yml", Kind::Rendered),
525 ("release-plz.toml", Kind::Seeded),
526 ("dist-workspace.toml", Kind::Seeded),
527 ("release-please-config.json", Kind::Seeded),
528 ("cliff.toml", Kind::Seeded),
529 ("nix/package.nix", Kind::Seeded),
530 ("flake.nix", Kind::Seeded),
531 (".release-please-manifest.json", Kind::State),
532 ("VERSION", Kind::State),
533 ("flake.lock", Kind::State),
534];
535
536pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
550
551pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
557
558pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
573
574pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
588 (
589 ".github/workflows/code-scanning-codeql.yml",
590 Provider::CodeQl,
591 ),
592 (
593 ".github/workflows/code-scanning-semgrep.yml",
594 Provider::Semgrep,
595 ),
596 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
597];
598
599pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
607
608#[must_use]
625pub fn code_scanning_incompatibility(
626 provider: Option<Provider>,
627 driver: Option<&str>,
628 forge: Option<&str>,
629) -> Option<String> {
630 let named = provider?;
631 let Some(driver) = driver else {
632 return Some(format!(
633 "a scanner reads the release driver's language, and the profile names no automatic release driver; the bindings that carry one are: {}",
634 CODE_SCANNING_TECHS.join(", ")
635 ));
636 };
637 if !CODE_SCANNING_TECHS.contains(&driver) {
638 return Some(format!(
639 "the {driver} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
640 CODE_SCANNING_TECHS.join(", ")
641 ));
642 }
643 let Some(forge) = forge else {
644 return Some(
645 "a code scanning workflow runs at a forge, and the profile names none".to_owned(),
646 );
647 };
648 if named == Provider::CodeQl && forge != "github" {
649 return Some(format!(
650 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
651 ));
652 }
653 None
654}
655
656const OSI_APPROVED: [&str; 18] = [
666 "0bsd",
667 "agpl-3.0",
668 "agpl-3.0-only",
669 "agpl-3.0-or-later",
670 "apache-2.0",
671 "bsd-2-clause",
672 "bsd-3-clause",
673 "bsl-1.0",
674 "epl-2.0",
675 "gpl-2.0",
676 "gpl-2.0-only",
677 "gpl-2.0-or-later",
678 "gpl-3.0",
679 "gpl-3.0-only",
680 "gpl-3.0-or-later",
681 "isc",
682 "mit",
683 "mpl-2.0",
684];
685
686const SPDX_EXCEPTIONS: [&str; 86] = [
703 "389-exception",
704 "asterisk-exception",
705 "asterisk-linking-protocols-exception",
706 "autoconf-exception-2.0",
707 "autoconf-exception-3.0",
708 "autoconf-exception-generic",
709 "autoconf-exception-generic-3.0",
710 "autoconf-exception-macro",
711 "bison-exception-1.24",
712 "bison-exception-2.2",
713 "bootloader-exception",
714 "cgal-linking-exception",
715 "classpath-exception-2.0",
716 "classpath-exception-2.0-short",
717 "clisp-exception-2.0",
718 "cryptsetup-openssl-exception",
719 "digia-qt-lgpl-exception-1.1",
720 "digirule-foss-exception",
721 "ecos-exception-2.0",
722 "erlang-otp-linking-exception",
723 "fawkes-runtime-exception",
724 "fltk-exception",
725 "fmt-exception",
726 "font-exception-2.0",
727 "freertos-exception-2.0",
728 "gcc-exception-2.0",
729 "gcc-exception-2.0-note",
730 "gcc-exception-3.1",
731 "gmsh-exception",
732 "gnat-exception",
733 "gnome-examples-exception",
734 "gnu-compiler-exception",
735 "gnu-javamail-exception",
736 "google-patent-webm",
737 "gpl-3.0-389-ds-base-exception",
738 "gpl-3.0-interface-exception",
739 "gpl-3.0-linking-exception",
740 "gpl-3.0-linking-source-exception",
741 "gpl-cc-1.0",
742 "gstreamer-exception-2005",
743 "gstreamer-exception-2008",
744 "harbour-exception",
745 "i2p-gpl-java-exception",
746 "independent-modules-exception",
747 "kicad-libraries-exception",
748 "kvirc-openssl-exception",
749 "lgpl-3.0-linking-exception",
750 "libpri-openh323-exception",
751 "libtool-exception",
752 "linux-syscall-note",
753 "llgpl",
754 "llvm-exception",
755 "lzma-exception",
756 "mif-exception",
757 "mxml-exception",
758 "nokia-qt-exception-1.1",
759 "ocaml-lgpl-linking-exception",
760 "occt-exception-1.0",
761 "openjdk-assembly-exception-1.0",
762 "openvpn-openssl-exception",
763 "pcre2-exception",
764 "polyparse-exception",
765 "ps-or-pdf-font-exception-20170817",
766 "qpl-1.0-inria-2004-exception",
767 "qt-gpl-exception-1.0",
768 "qt-lgpl-exception-1.1",
769 "qwt-exception-1.0",
770 "romic-exception",
771 "rrdtool-floss-exception-2.0",
772 "rsync-linking-exception",
773 "sane-exception",
774 "shl-2.0",
775 "shl-2.1",
776 "simple-library-usage-exception",
777 "spelling-provider-lgpl-exception",
778 "sqlitestudio-openssl-exception",
779 "stunnel-exception",
780 "swi-exception",
781 "swift-exception",
782 "texinfo-exception",
783 "u-boot-exception-2.0",
784 "ubdl-exception",
785 "universal-foss-exception-1.0",
786 "vsftpd-openssl-exception",
787 "wxwindows-exception-3.1",
788 "x11vnc-openssl-exception",
789];
790
791fn listed(list: &[&str], identifier: &str) -> bool {
798 let lowered = identifier.to_ascii_lowercase();
799 list.contains(&lowered.as_str())
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
804enum Token<'a> {
805 Open,
807 Close,
809 And,
811 Or,
813 With,
816 Identifier(&'a str),
819}
820
821fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
829 let mut tokens = Vec::new();
830 let bytes = expression.as_bytes();
831 let mut at = 0;
832 while at < bytes.len() {
833 let byte = bytes[at];
834 if byte.is_ascii_whitespace() {
835 at += 1;
836 continue;
837 }
838 if byte == b'(' {
839 tokens.push(Token::Open);
840 at += 1;
841 continue;
842 }
843 if byte == b')' {
844 tokens.push(Token::Close);
845 at += 1;
846 continue;
847 }
848 let start = at;
849 while at < bytes.len() {
850 let byte = bytes[at];
851 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
852 at += 1;
853 } else {
854 break;
855 }
856 }
857 if at == start {
858 return None;
859 }
860 let word = &expression[start..at];
861 tokens.push(match word {
862 "AND" => Token::And,
863 "OR" => Token::Or,
864 "WITH" => Token::With,
865 other => Token::Identifier(other),
866 });
867 }
868 Some(tokens)
869}
870
871struct Spdx<'a> {
879 tokens: &'a [Token<'a>],
880 at: usize,
881}
882
883impl<'a> Spdx<'a> {
884 fn peek(&self) -> Option<Token<'a>> {
886 self.tokens.get(self.at).copied()
887 }
888
889 fn bump(&mut self) -> Option<Token<'a>> {
891 let token = self.peek()?;
892 self.at += 1;
893 Some(token)
894 }
895
896 fn expression(&mut self) -> Option<bool> {
903 let mut approved = self.operand()?;
904 while matches!(self.peek(), Some(Token::And | Token::Or)) {
905 self.bump();
906 approved = self.operand()? && approved;
907 }
908 Some(approved)
909 }
910
911 fn operand(&mut self) -> Option<bool> {
921 match self.bump()? {
922 Token::Open => {
923 let inner = self.expression()?;
924 (self.bump()? == Token::Close).then_some(inner)
925 }
926 Token::Identifier(name) => {
927 let identifier = name.strip_suffix('+').unwrap_or(name);
928 let approved = listed(&OSI_APPROVED, identifier);
929 if self.peek() == Some(Token::With) {
930 self.bump();
931 match self.bump()? {
935 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
936 _ => return None,
937 }
938 }
939 Some(approved)
940 }
941 Token::And | Token::Or | Token::With | Token::Close => None,
942 }
943 }
944}
945
946#[must_use]
954pub fn licence_is_osi_approved(expression: &str) -> bool {
955 let Some(tokens) = tokenize(expression) else {
956 return false;
957 };
958 let mut reader = Spdx {
959 tokens: &tokens,
960 at: 0,
961 };
962 let Some(approved) = reader.expression() else {
963 return false;
964 };
965 approved && reader.at == tokens.len()
966}
967
968#[must_use]
982pub fn code_scanning_licence_refusal(
983 provider: Option<Provider>,
984 driver: Option<&str>,
985 shape: &CrateShape,
986) -> Option<String> {
987 if provider != Some(Provider::CodeQl) {
988 return None;
989 }
990 if driver != Some("rust") {
991 return Some(format!(
992 "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",
993 driver.unwrap_or("release-less")
994 ));
995 }
996 let fallback = "land --code-scanning semgrep, which carries no licence condition";
997 let Some(text) = shape.cargo_toml.as_deref() else {
998 return Some(format!(
999 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
1000 ));
1001 };
1002 let Ok(table) = text.parse::<toml::Table>() else {
1003 return Some(format!(
1004 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
1005 ));
1006 };
1007 let licence = table
1008 .get("package")
1009 .and_then(toml::Value::as_table)
1010 .and_then(|package| package.get("license"))
1011 .and_then(toml::Value::as_str);
1012 match licence {
1013 None => Some(format!(
1014 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
1015 )),
1016 Some(expression) if !licence_is_osi_approved(expression) => Some(format!(
1017 "the target's license, {expression}, is not one this release recognizes as OSI-approved, and codeql's terms cover an open-source codebase alone; {fallback}"
1018 )),
1019 Some(_) => None,
1020 }
1021}
1022
1023#[must_use]
1026pub fn kind_of(destination: &str) -> Option<Kind> {
1027 if BLOCK_DESTINATIONS.contains(&destination) {
1028 return Some(Kind::Rendered);
1029 }
1030 KINDS
1031 .iter()
1032 .find(|(name, _)| *name == destination)
1033 .map(|(_, kind)| *kind)
1034}
1035
1036pub fn destinations() -> impl Iterator<Item = &'static str> {
1041 KINDS
1042 .iter()
1043 .map(|(name, _)| *name)
1044 .chain(BLOCK_DESTINATIONS)
1045}
1046
1047pub const OWNER_TOKEN: &[u8] = b"OWNER";
1054
1055pub const REPO_PLACEHOLDER: &str = "OWNER";
1060
1061pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1063
1064pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1066
1067pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1070
1071pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1075
1076pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1079
1080pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1086
1087pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1098 (
1099 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1100 b"<!--RK_SECURITY_CONTACT_END-->",
1101 ),
1102 (
1103 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1104 b"<!--RK_SECURITY_RESPONSE_END-->",
1105 ),
1106 (
1107 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1108 b"<!--RK_SECURITY_DEADLINE_END-->",
1109 ),
1110];
1111
1112fn acknowledgment(response: &str) -> String {
1115 format!("Maintainers acknowledge a report within {response}.")
1116}
1117
1118const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1122
1123fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1126 let contact = (!params.security_contact().is_empty())
1127 .then(|| params.security_contact().as_bytes().to_vec());
1128 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1129 [
1130 contact,
1131 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1132 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1133 ]
1134}
1135
1136fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1142 let ordered = find(baseline, begin)
1143 .zip(find(baseline, end))
1144 .filter(|(start, stop)| stop > start);
1145 let Some((start, stop)) = ordered else {
1146 return baseline.to_vec();
1147 };
1148 let mut out = Vec::with_capacity(baseline.len());
1149 out.extend_from_slice(&baseline[..start]);
1150 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1151 out.extend_from_slice(&baseline[stop + end.len()..]);
1152 out
1153}
1154
1155#[must_use]
1173pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1174 let repo = params.repo();
1175 let owner = repo.split('/').next().unwrap_or(repo);
1176 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
1177 if let Some(style) = params.style() {
1178 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1179 }
1180 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1181 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1182 let escaped = params.line_prefix().replace('/', "\\/");
1183 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1184 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1185 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1186 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1187 out = replace_span(&out, begin, end, value.as_deref());
1188 }
1189 out
1190}
1191
1192#[must_use]
1194pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1195 let mut out = Vec::with_capacity(baseline.len());
1196 let mut rest = baseline;
1197 while let Some(at) = find(rest, token) {
1198 out.extend_from_slice(&rest[..at]);
1199 out.extend_from_slice(value);
1200 rest = &rest[at + token.len()..];
1201 }
1202 out.extend_from_slice(rest);
1203 out
1204}
1205
1206fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1208 haystack
1209 .windows(needle.len())
1210 .position(|window| window == needle)
1211}
1212
1213pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1215
1216pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1218
1219pub const BLOCK_END: &str = "<!-- END release-kit -->";
1221
1222pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1227
1228pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1230
1231pub const BLOCK_DESTINATIONS: [&str; 3] =
1237 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1238
1239pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1241
1242pub const HOOKS_END: &str = "# END release-kit";
1244
1245pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1249
1250pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1252
1253pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1255
1256pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
1258
1259pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
1261
1262pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1264
1265pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1267
1268#[must_use]
1270pub const fn routing_line(mode: CheckoutMode) -> &'static str {
1271 match mode {
1272 CheckoutMode::LinkedWorktree => AGENTS_LINE_WORKTREE,
1273 CheckoutMode::MainWorktree => AGENTS_LINE_BRANCHES,
1274 }
1275}
1276
1277pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1284 let name = path.strip_prefix("blocks/").unwrap_or(path);
1285 let file = embedded::BLOCKS
1286 .get_file(name)
1287 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1288 std::str::from_utf8(file.contents())
1289 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1290}
1291
1292#[must_use]
1296pub fn authored(text: &str) -> &str {
1297 text.strip_suffix('\n').unwrap_or(text)
1298}
1299
1300pub 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[-/].+)$";
1308
1309pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1319
1320#[must_use]
1327pub fn scope_is_shaped(scope: &str) -> bool {
1328 !scope.is_empty()
1329 && scope.chars().all(|c| {
1330 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1331 })
1332}
1333
1334#[must_use]
1341pub fn compose_routing(template: &str, line: &str) -> String {
1342 authored(template).replacen("RK_WORKFLOW_LINE", authored(line), 1)
1343}
1344
1345#[must_use]
1349pub fn compose_glossary(template: &str) -> String {
1350 authored(template).to_owned()
1351}
1352
1353#[must_use]
1363pub fn compose_hooks(template: &str, guard: Option<&str>) -> String {
1364 let (guard, skip) = guard.map_or_else(
1365 || (String::new(), "no-commit-to-branch"),
1366 |entry| {
1367 (
1368 format!("{}\n", authored(entry)),
1369 "no-commit-to-branch,rk-worktree-location",
1370 )
1371 },
1372 );
1373 authored(template)
1374 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
1375 .replacen("RK_SWEEP_SKIP", skip, 1)
1376 .replacen("RK_WORKTREE_GUARD", &guard, 1)
1377}
1378
1379pub fn routing_block(mode: CheckoutMode) -> Result<String, RkError> {
1386 Ok(compose_routing(
1387 embedded_block(AGENTS_BLOCK)?,
1388 embedded_block(routing_line(mode))?,
1389 ))
1390}
1391
1392pub fn glossary_block() -> Result<String, RkError> {
1398 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
1399}
1400
1401pub fn hooks_block(mode: CheckoutMode) -> Result<String, RkError> {
1408 let guard = match mode {
1409 CheckoutMode::LinkedWorktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
1410 CheckoutMode::MainWorktree => None,
1411 };
1412 Ok(compose_hooks(embedded_block(PRE_COMMIT_BLOCK)?, guard))
1413}
1414
1415fn block_template(destination: &str, mode: CheckoutMode) -> Result<(String, Vec<String>), RkError> {
1418 match destination {
1419 AGENTS_DESTINATION => Ok((
1420 routing_block(mode)?,
1421 vec![AGENTS_BLOCK.to_owned(), routing_line(mode).to_owned()],
1422 )),
1423 GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
1424 HOOKS_DESTINATION => {
1425 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
1426 if mode == CheckoutMode::LinkedWorktree {
1427 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
1428 }
1429 Ok((hooks_block(mode)?, sources))
1430 }
1431 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
1432 }
1433}
1434
1435#[must_use]
1437pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
1438 match destination {
1439 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
1440 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
1441 _ => None,
1442 }
1443}
1444
1445#[must_use]
1448pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
1449 let start = text.find(begin)?;
1450 let stop = text[start..].find(end)? + start + end.len();
1451 Some(&text[start..stop])
1452}
1453
1454#[must_use]
1461pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
1462 let block = block.as_bytes();
1463 let Some(text) = existing else {
1464 return [block, b"\n"].concat();
1465 };
1466 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
1470 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
1471 {
1472 let stop = start + offset + BLOCK_END.len();
1473 return [&text[..start], block, &text[stop..]].concat();
1474 }
1475 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
1479 out.extend_from_slice(text);
1480 if !text.ends_with(b"\n") {
1481 out.push(b'\n');
1482 }
1483 out.push(b'\n');
1484 out.extend_from_slice(block);
1485 out.push(b'\n');
1486 out
1487}
1488
1489pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
1502 let Some(text) = existing else {
1503 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
1504 };
1505 if let Some(defect) = hooks_marker_defect(text) {
1506 return Err(defect);
1507 }
1508 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
1509 return Ok(text.replacen(found, block, 1));
1510 }
1511 let mut out = String::with_capacity(text.len() + block.len() + 1);
1512 let mut placed = false;
1513 for line in text.split_inclusive('\n') {
1514 out.push_str(line);
1515 if !placed && line.trim_end() == "repos:" {
1516 if !out.ends_with('\n') {
1517 out.push('\n');
1518 }
1519 out.push_str(block);
1520 out.push('\n');
1521 placed = true;
1522 }
1523 }
1524 if placed {
1525 Ok(out)
1526 } else {
1527 Err(format!(
1528 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
1529 ))
1530 }
1531}
1532
1533#[must_use]
1543pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
1544 let (begin, end) = block_markers(destination)?;
1545 let begins = text.matches(begin).count();
1546 let ends = text.matches(end).count();
1547 if begins > 1 || ends > 1 {
1548 return Some(format!(
1549 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
1550 ));
1551 }
1552 match (text.find(begin), text.find(end)) {
1553 (Some(begin), Some(end)) if end > begin => None,
1554 (None, None) => None,
1555 _ => Some(format!(
1556 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1557 )),
1558 }
1559}
1560
1561#[must_use]
1564pub fn hooks_marker_defect(text: &str) -> Option<String> {
1565 marker_defect(HOOKS_DESTINATION, text)
1566}
1567
1568fn propose_document(
1572 destination: &str,
1573 existing: Option<&[u8]>,
1574 region: &[u8],
1575) -> Result<Vec<u8>, String> {
1576 let block = String::from_utf8_lossy(region).into_owned();
1581 if let Some(text) = existing
1582 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1583 {
1584 return Err(defect);
1585 }
1586 if destination == HOOKS_DESTINATION {
1587 let text = match existing {
1591 None => None,
1592 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1593 format!(
1594 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1595 )
1596 })?),
1597 };
1598 return splice_hooks_block(text, &block).map(String::into_bytes);
1599 }
1600 Ok(splice_marked_block(existing, &block))
1601}
1602
1603#[must_use]
1615pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1616 let Some(text) = shape.cargo_toml.as_deref() else {
1617 return Some(
1618 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1619 );
1620 };
1621 let Ok(table) = text.parse::<toml::Table>() else {
1622 return Some(
1623 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1624 );
1625 };
1626 if !table.contains_key("package") {
1627 return Some(
1628 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1629 );
1630 }
1631 if !shape.cargo_lock {
1632 return Some(
1633 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1634 );
1635 }
1636 let implicit_bin = shape.main_rs
1637 && table
1638 .get("package")
1639 .and_then(toml::Value::as_table)
1640 .and_then(|package| package.get("autobins"))
1641 .and_then(toml::Value::as_bool)
1642 != Some(false);
1643 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1644 if explicit_bins.is_none() && !implicit_bin {
1645 return Some(
1646 "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(),
1647 );
1648 }
1649 if let Some(bins) = explicit_bins {
1655 let required = bins
1656 .first()
1657 .and_then(toml::Value::as_table)
1658 .and_then(|bin| bin.get("required-features"))
1659 .and_then(toml::Value::as_array);
1660 if let Some(required) = required {
1661 let enabled = default_features(&table);
1662 let missing = required
1663 .iter()
1664 .filter_map(toml::Value::as_str)
1665 .any(|feature| !enabled.contains(feature));
1666 if missing {
1667 return Some(
1668 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1669 );
1670 }
1671 }
1672 }
1673 None
1674}
1675
1676fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1679 let edge = format!("dep:{name}");
1680 features.values().any(|list| {
1681 list.as_array().is_some_and(|entries| {
1682 entries
1683 .iter()
1684 .filter_map(toml::Value::as_str)
1685 .any(|entry| entry == edge)
1686 })
1687 })
1688}
1689
1690fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1693 ["dependencies", "build-dependencies"]
1694 .iter()
1695 .any(|section| {
1696 table
1697 .get(*section)
1698 .and_then(toml::Value::as_table)
1699 .and_then(|dependencies| dependencies.get(name))
1700 .and_then(toml::Value::as_table)
1701 .and_then(|dependency| dependency.get("optional"))
1702 .and_then(toml::Value::as_bool)
1703 == Some(true)
1704 })
1705}
1706
1707fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1714 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1715 return std::collections::BTreeSet::new();
1716 };
1717 let mut enabled = std::collections::BTreeSet::new();
1718 let mut queue = vec!["default".to_owned()];
1719 while let Some(name) = queue.pop() {
1720 if !enabled.insert(name.clone()) {
1721 continue;
1722 }
1723 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1724 for implied in implies.iter().filter_map(toml::Value::as_str) {
1725 if implied.starts_with("dep:") || implied.contains("?/") {
1726 continue;
1730 }
1731 if let Some((package, _)) = implied.split_once('/') {
1732 let feature_exists =
1740 features.contains_key(package) || !dep_edge_suppresses(features, package);
1741 if is_optional_dependency(table, package) && feature_exists {
1742 queue.push(package.to_owned());
1743 }
1744 } else {
1745 queue.push(implied.to_owned());
1746 }
1747 }
1748 }
1749 }
1750 enabled
1751}
1752
1753#[must_use]
1761pub fn flake_pair_withheld(
1762 flake_recorded: bool,
1763 flake_nix_present: bool,
1764 flake_lock_present: bool,
1765) -> Option<String> {
1766 if flake_recorded {
1767 return None;
1768 }
1769 let present: Vec<&str> = [
1770 ("flake.nix", flake_nix_present),
1771 ("flake.lock", flake_lock_present),
1772 ]
1773 .into_iter()
1774 .filter_map(|(name, present)| present.then_some(name))
1775 .collect();
1776 if present.is_empty() {
1777 return None;
1778 }
1779 Some(format!(
1780 "the target already carries {}; its flake pair stays its own",
1781 present.join(" and ")
1782 ))
1783}
1784
1785#[must_use]
1795pub fn nix_withholding(
1796 nix: bool,
1797 evidence: &TargetEvidence,
1798) -> Option<(&'static [&'static str], String)> {
1799 if !nix {
1800 return None;
1801 }
1802 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
1803 return Some((&NIX_DESTINATIONS[..], reason));
1804 }
1805 flake_pair_withheld(
1806 evidence.flake_recorded,
1807 evidence.flake_nix_present,
1808 evidence.flake_lock_present,
1809 )
1810 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
1811}
1812
1813#[cfg(test)]
1814mod tests {
1815 use super::{
1816 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
1817 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
1818 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
1819 };
1820 use crate::landing::{Params, Style};
1821
1822 fn supported_shape() -> CrateShape {
1824 CrateShape {
1825 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
1826 cargo_lock: true,
1827 main_rs: true,
1828 }
1829 }
1830
1831 fn input(evidence: TargetEvidence) -> ProjectionInput {
1832 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1833 params.set_nix_for_test(true);
1834 ProjectionInput { params, evidence }
1835 }
1836
1837 fn compute(evidence: TargetEvidence) -> Projection {
1838 Projection::compute(&input(evidence)).expect("the embedded pair projects")
1839 }
1840
1841 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
1842 projection
1843 .candidates
1844 .iter()
1845 .find(|candidate| candidate.destination == destination)
1846 .expect("the destination projects")
1847 }
1848
1849 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
1851 let text = String::from_utf8_lossy(bytes);
1852 let start = text.find(begin).expect("the begin marker is present");
1853 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
1854 (bytes[..start].to_vec(), bytes[stop..].to_vec())
1855 }
1856
1857 #[test]
1858 fn equal_projection_inputs_yield_byte_identical_projections() {
1859 let mut documents = std::collections::BTreeMap::new();
1860 documents.insert(
1861 AGENTS_DESTINATION.to_owned(),
1862 b"# Widget\n\nOwn rules.\n".to_vec(),
1863 );
1864 let evidence = TargetEvidence {
1865 documents,
1866 crate_shape: supported_shape(),
1867 ..TargetEvidence::default()
1868 };
1869 let first = input(evidence.clone());
1870 let second = input(evidence);
1871 assert_eq!(first, second, "the inputs are values and compare equal");
1872 let a = Projection::compute(&first).expect("the pair projects");
1873 let b = Projection::compute(&second).expect("the pair projects");
1874 assert_eq!(a.candidates.len(), b.candidates.len());
1875 for (x, y) in a.candidates.iter().zip(&b.candidates) {
1876 assert_eq!(x.destination, y.destination);
1877 assert_eq!(x.kind, y.kind);
1878 assert_eq!(x.placement, y.placement);
1879 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
1880 assert_eq!(x.region, y.region, "{}", x.destination);
1881 assert_eq!(x.sources, y.sources, "{}", x.destination);
1882 }
1883 assert_eq!(a, b);
1884 let destinations: Vec<&str> = a
1885 .candidates
1886 .iter()
1887 .map(|candidate| candidate.destination.as_str())
1888 .collect();
1889 let mut sorted = destinations.clone();
1890 sorted.sort_unstable();
1891 assert_eq!(destinations, sorted, "candidates sort by destination");
1892 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
1893 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
1894 }
1895
1896 #[test]
1900 fn the_scorecard_destination_projects_only_under_the_opt_in() {
1901 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
1902 let destination = SCORECARD_DESTINATIONS[0];
1903 assert_eq!(kind_of(destination), Some(Kind::Rendered));
1904
1905 let project = |scorecard: bool| {
1906 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1907 params.set_scorecard_for_test(scorecard);
1908 Projection::compute(&ProjectionInput {
1909 params,
1910 evidence: TargetEvidence::default(),
1911 })
1912 .expect("the embedded pair projects")
1913 };
1914
1915 let off = project(false);
1916 assert!(
1917 !off.candidates
1918 .iter()
1919 .any(|candidate| candidate.destination == destination),
1920 "off by default, and an absent candidate is no omission"
1921 );
1922 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
1923
1924 let on = project(true);
1925 let candidate = candidate(&on, destination);
1926 assert_eq!(candidate.kind, Kind::Rendered);
1927 assert_eq!(candidate.placement, Placement::Whole);
1928 let text = String::from_utf8_lossy(&candidate.bytes);
1929 assert!(!text.contains("RK_"), "a token survived: {text}");
1930 }
1931
1932 #[test]
1936 fn the_licence_judgment_reads_every_operand() {
1937 use super::licence_is_osi_approved as approved;
1938 for expression in [
1939 "MIT",
1940 "Apache-2.0",
1941 "MIT OR Apache-2.0",
1942 "MIT AND Apache-2.0",
1943 "(MIT OR Apache-2.0) AND ISC",
1944 "Apache-2.0 WITH LLVM-exception OR MIT",
1945 "GPL-3.0+",
1946 ] {
1947 assert!(approved(expression), "{expression} is OSI-approved");
1948 }
1949 for expression in [
1950 "",
1951 " ",
1952 "LicenseRef-proprietary",
1953 "MIT AND LicenseRef-proprietary",
1954 "SEE LICENSE IN COPYING",
1955 "CC-BY-4.0",
1956 "DocumentRef-spdx:LicenseRef-proprietary",
1957 ] {
1958 assert!(!approved(expression), "{expression} is not recognized");
1959 }
1960 for expression in [
1965 "MIT OR",
1966 "OR MIT",
1967 "MIT AND",
1968 "MIT WITH",
1969 "WITH LLVM-exception",
1970 "(MIT",
1971 "MIT)",
1972 "MIT Apache-2.0",
1973 "()",
1974 "(MIT OR Apache-2.0",
1975 "MIT OR (Apache-2.0",
1976 "MIT OR ()",
1977 "AND",
1978 "(",
1979 ")",
1980 "MIT WITH AND ISC",
1981 "MIT OR OR ISC",
1982 "MIT @ Apache-2.0",
1983 ] {
1984 assert!(!approved(expression), "{expression} is malformed");
1985 }
1986 for expression in [
1988 "MIT AND (Apache-2.0 OR ISC)",
1989 "((MIT))",
1990 "Apache-2.0 WITH LLVM-exception AND ISC",
1991 "(Apache-2.0 WITH LLVM-exception)",
1992 ] {
1993 assert!(approved(expression), "{expression} is well formed");
1994 }
1995 for expression in [
2000 "mit",
2001 "MiT",
2002 "apache-2.0 OR mit",
2003 "APACHE-2.0 WITH llvm-exception",
2004 ] {
2005 assert!(
2006 approved(expression),
2007 "{expression} names an approved licence"
2008 );
2009 }
2010 for expression in [
2014 "MIT or Apache-2.0",
2015 "MIT and Apache-2.0",
2016 "MIT with LLVM-exception",
2017 ] {
2018 assert!(!approved(expression), "{expression} carries no operator");
2019 }
2020 for expression in [
2024 "MIT WITH definitely-not-an-spdx-exception",
2025 "MIT WITH MIT",
2026 "MIT WITH Apache-2.0",
2027 ] {
2028 assert!(!approved(expression), "{expression} names no exception");
2029 }
2030 for expression in [
2034 "GPL-2.0-only WITH GCC-exception-2.0",
2035 "GPL-2.0-or-later WITH Classpath-exception-2.0",
2036 "Apache-2.0 WITH Swift-exception",
2037 "GPL-3.0-only WITH Autoconf-exception-generic",
2038 ] {
2039 assert!(approved(expression), "{expression} names a real exception");
2040 }
2041 }
2042
2043 #[test]
2049 fn a_recorded_provider_its_pair_cannot_run_is_named() {
2050 use super::{Provider, code_scanning_incompatibility};
2051
2052 assert!(code_scanning_incompatibility(None, Some("bash"), Some("gitlab")).is_none());
2053 assert!(
2054 code_scanning_incompatibility(Some(Provider::Semgrep), Some("rust"), Some("gitlab"))
2055 .is_none()
2056 );
2057 assert!(
2058 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("github"))
2059 .is_none()
2060 );
2061
2062 let bash =
2063 code_scanning_incompatibility(Some(Provider::Semgrep), Some("bash"), Some("github"))
2064 .expect("a binding with no scanner is named");
2065 assert!(bash.contains("bash binding"), "{bash}");
2066 let gitlab =
2067 code_scanning_incompatibility(Some(Provider::CodeQl), Some("rust"), Some("gitlab"))
2068 .expect("codeql on gitlab is named");
2069 assert!(gitlab.contains("codeql"), "{gitlab}");
2070 let release_less =
2071 code_scanning_incompatibility(Some(Provider::Semgrep), None, Some("github"))
2072 .expect("no driver is named");
2073 assert!(
2074 release_less.contains("no automatic release driver"),
2075 "{release_less}"
2076 );
2077
2078 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2081 params.set_code_scanning_for_test(Some(Provider::Semgrep));
2082 let clean = Projection::compute(&ProjectionInput {
2083 params: params.clone(),
2084 evidence: TargetEvidence::default(),
2085 })
2086 .expect("the pair projects");
2087 assert!(
2088 clean.record_defects.is_empty(),
2089 "{:?}",
2090 clean.record_defects
2091 );
2092 }
2093
2094 #[test]
2098 fn the_code_scanning_destinations_follow_the_recorded_provider() {
2099 use super::{
2100 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
2101 };
2102 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2103 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
2104 }
2105
2106 let project = |provider: Option<Provider>| {
2107 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2108 params.set_code_scanning_for_test(provider);
2109 Projection::compute(&ProjectionInput {
2110 params,
2111 evidence: TargetEvidence {
2112 crate_shape: CrateShape {
2113 cargo_toml: Some(
2114 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
2115 ),
2116 ..CrateShape::default()
2117 },
2118 ..TargetEvidence::default()
2119 },
2120 })
2121 .expect("the embedded pair projects")
2122 };
2123 let landed = |projection: &Projection, destination: &str| {
2124 projection
2125 .candidates
2126 .iter()
2127 .any(|candidate| candidate.destination == destination)
2128 };
2129
2130 let off = project(None);
2131 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2132 assert!(!landed(&off, destination), "{destination}");
2133 }
2134 assert!(off.licence_refusal.is_none());
2135
2136 let codeql = project(Some(Provider::CodeQl));
2137 assert!(landed(
2138 &codeql,
2139 ".github/workflows/code-scanning-codeql.yml"
2140 ));
2141 assert!(!landed(
2142 &codeql,
2143 ".github/workflows/code-scanning-semgrep.yml"
2144 ));
2145 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
2146
2147 let semgrep = project(Some(Provider::Semgrep));
2148 assert!(landed(
2149 &semgrep,
2150 ".github/workflows/code-scanning-semgrep.yml"
2151 ));
2152 assert!(!landed(
2153 &semgrep,
2154 ".github/workflows/code-scanning-codeql.yml"
2155 ));
2156
2157 let proprietary = CrateShape {
2159 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
2160 ..CrateShape::default()
2161 };
2162 assert!(
2163 code_scanning_licence_refusal(Some(Provider::Semgrep), Some("rust"), &proprietary)
2164 .is_none()
2165 );
2166 let refusal =
2167 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("rust"), &proprietary)
2168 .expect("codeql refuses a licence its terms do not cover");
2169 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
2170 assert!(refusal.contains("semgrep"), "{refusal}");
2171 let bash =
2174 code_scanning_licence_refusal(Some(Provider::CodeQl), Some("bash"), &proprietary)
2175 .expect("an unread binding refuses");
2176 assert!(bash.contains("bash binding"), "{bash}");
2177 }
2178
2179 #[test]
2185 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
2186 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
2187 let text = std::fs::read_to_string(&path).expect("the source reads");
2188 let production = text.split("#[cfg(test)]").next().unwrap_or("");
2189 let needles = [
2190 "std::fs",
2191 "std::env",
2192 "std::process",
2193 "std::time",
2194 "SystemTime",
2195 "Instant",
2196 "std::net",
2197 "Command::new",
2198 "registry::",
2199 "curl",
2200 "reqwest",
2201 "blob(",
2202 ];
2203 let mut hits = Vec::new();
2204 for (index, line) in production.lines().enumerate() {
2205 if line.trim_start().starts_with("//") {
2206 continue;
2207 }
2208 for needle in needles {
2209 if line.contains(needle) {
2210 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
2211 }
2212 }
2213 }
2214 assert!(
2215 hits.is_empty(),
2216 "the projection reads beyond its inputs: {hits:?}"
2217 );
2218 }
2219
2220 #[test]
2221 #[allow(
2222 clippy::too_many_lines,
2223 reason = "one test walks the three marked destinations and the three unmarked shapes"
2224 )]
2225 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
2226 let agents_before = "# Widget\n\nOperator prose above.\n\n";
2227 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
2228 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
2229 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
2230 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
2231 let hooks_after =
2232 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2233 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
2234 let mut documents = std::collections::BTreeMap::new();
2235 documents.insert(
2236 AGENTS_DESTINATION.to_owned(),
2237 format!(
2238 "{agents_before}{}{agents_after}",
2239 stale(BLOCK_BEGIN, BLOCK_END)
2240 )
2241 .into_bytes(),
2242 );
2243 documents.insert(
2244 GLOSSARY_DESTINATION.to_owned(),
2245 format!(
2246 "{glossary_before}{}{glossary_after}",
2247 stale(BLOCK_BEGIN, BLOCK_END)
2248 )
2249 .into_bytes(),
2250 );
2251 documents.insert(
2252 HOOKS_DESTINATION.to_owned(),
2253 format!(
2254 "{hooks_before}{}{hooks_after}",
2255 stale(HOOKS_BEGIN, HOOKS_END)
2256 )
2257 .into_bytes(),
2258 );
2259 let projection = compute(TargetEvidence {
2260 documents: documents.clone(),
2261 crate_shape: supported_shape(),
2262 ..TargetEvidence::default()
2263 });
2264 assert!(
2265 projection.collisions.is_empty(),
2266 "{:?}",
2267 projection.collisions
2268 );
2269 for (destination, before, after) in [
2270 (AGENTS_DESTINATION, agents_before, agents_after),
2271 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
2272 (HOOKS_DESTINATION, hooks_before, hooks_after),
2273 ] {
2274 let candidate = candidate(&projection, destination);
2275 let Placement::Region { begin, end } = candidate.placement else {
2276 panic!("{destination} is a region");
2277 };
2278 let region = candidate
2279 .region
2280 .as_deref()
2281 .expect("a region carries its block");
2282 let (head, tail) = outside(&candidate.bytes, begin, end);
2283 assert_eq!(
2284 head,
2285 before.as_bytes(),
2286 "{destination}: bytes before the markers"
2287 );
2288 assert_eq!(
2289 tail,
2290 after.as_bytes(),
2291 "{destination}: bytes after the markers"
2292 );
2293 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
2294 assert_eq!(
2295 inside, region,
2296 "{destination}: the region is the rendered block"
2297 );
2298 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
2299 assert_eq!(head, existing_head);
2300 assert_eq!(tail, existing_tail);
2301 }
2302
2303 let own_hooks =
2307 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2308 let own_agents = "# Widget\n\nOwn rules.";
2309 let mut documents = std::collections::BTreeMap::new();
2310 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
2311 documents.insert(
2312 AGENTS_DESTINATION.to_owned(),
2313 own_agents.as_bytes().to_vec(),
2314 );
2315 let projection = compute(TargetEvidence {
2316 documents,
2317 crate_shape: supported_shape(),
2318 ..TargetEvidence::default()
2319 });
2320 assert!(
2321 projection.collisions.is_empty(),
2322 "{:?}",
2323 projection.collisions
2324 );
2325 let hooks = candidate(&projection, HOOKS_DESTINATION);
2326 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
2327 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
2328 assert!(
2329 hooks_text.starts_with(&format!(
2330 "repos:\n{region}\n - repo: https://example.com/own"
2331 )),
2332 "{hooks_text}"
2333 );
2334 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
2335 let agents = candidate(&projection, AGENTS_DESTINATION);
2336 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
2337 assert_eq!(
2338 extract_block(
2339 &String::from_utf8_lossy(&agents.bytes),
2340 BLOCK_BEGIN,
2341 BLOCK_END
2342 )
2343 .map(str::as_bytes),
2344 agents.region.as_deref()
2345 );
2346 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
2347 let region = glossary.region.as_deref().expect("a region");
2348 assert_eq!(
2349 glossary.bytes,
2350 [region, b"\n"].concat(),
2351 "an absent file is fresh"
2352 );
2353 }
2354
2355 #[test]
2361 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
2362 let mut documents = std::collections::BTreeMap::new();
2363 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
2364 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
2365 invalid.extend_from_slice(b" - repo: local \xff below\n");
2366 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
2367 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
2368 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
2369 agents.extend_from_slice(b"r\xe9sum\xe9\n");
2370 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
2371 let projection = compute(TargetEvidence {
2372 documents,
2373 crate_shape: supported_shape(),
2374 ..TargetEvidence::default()
2375 });
2376 let collided: Vec<&str> = projection
2377 .collisions
2378 .iter()
2379 .map(|c| c.destination.as_str())
2380 .collect();
2381 assert_eq!(collided, [HOOKS_DESTINATION]);
2382 assert!(
2383 projection.collisions[0].reason.contains("not UTF-8"),
2384 "{}",
2385 projection.collisions[0].reason
2386 );
2387 assert!(
2388 !projection
2389 .candidates
2390 .iter()
2391 .any(|c| c.destination == HOOKS_DESTINATION),
2392 "a colliding destination projects no candidate"
2393 );
2394 let agents = candidate(&projection, AGENTS_DESTINATION);
2395 assert!(
2396 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
2397 "{:?}",
2398 agents.bytes
2399 );
2400 assert!(
2401 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
2402 "{:?}",
2403 agents.bytes
2404 );
2405 assert!(
2406 !agents.bytes.contains(&0xEF),
2407 "a replacement character landed"
2408 );
2409
2410 let mut documents = std::collections::BTreeMap::new();
2411 let valid =
2412 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
2413 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
2414 let projection = compute(TargetEvidence {
2415 documents,
2416 crate_shape: supported_shape(),
2417 ..TargetEvidence::default()
2418 });
2419 assert!(
2420 projection.collisions.is_empty(),
2421 "{:?}",
2422 projection.collisions
2423 );
2424 let hooks = candidate(&projection, HOOKS_DESTINATION);
2425 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
2426 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
2427 assert!(text.ends_with("\n - repo: local\n"), "{text}");
2428 assert!(!text.contains("stale"), "the region is replaced: {text}");
2429 }
2430
2431 #[test]
2435 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
2436 let files: Vec<(String, &[u8])> = vec![
2437 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
2438 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
2439 ];
2440 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2441 .expect_err("a whole file at a block destination refuses");
2442 let text = err.to_string();
2443 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
2444 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
2445 assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
2446 assert!(text.contains("embedded sources are defective"), "{text}");
2447 }
2448
2449 #[test]
2450 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
2451 {
2452 let files: Vec<(String, &[u8])> = vec![
2455 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2456 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
2457 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2458 ];
2459 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2460 .expect_err("a doubled destination refuses");
2461 let text = err.to_string();
2462 assert!(
2463 text.contains("snippets/_shared/github/SECURITY.md"),
2464 "{text}"
2465 );
2466 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
2467 assert!(text.contains("embedded sources are defective"), "{text}");
2468
2469 let clean: Vec<(String, &[u8])> = vec![
2470 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2471 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2472 ];
2473 let projection = Projection::compute_over(&clean, &input(TargetEvidence::default()))
2474 .expect("a clean list projects");
2475 let whole: Vec<&str> = projection
2476 .candidates
2477 .iter()
2478 .filter(|c| c.placement == Placement::Whole)
2479 .map(|c| c.destination.as_str())
2480 .collect();
2481 assert_eq!(whole, ["SECURITY.md", "release-plz.toml"]);
2482 assert_eq!(
2483 projection
2484 .candidates
2485 .iter()
2486 .find(|c| c.destination == "SECURITY.md")
2487 .map(|c| c.sources.clone()),
2488 Some(vec!["snippets/_shared/github/SECURITY.md".to_owned()])
2489 );
2490
2491 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
2492 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
2493 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
2494 let mut documents = std::collections::BTreeMap::new();
2495 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
2496 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
2497 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
2498 let projection = compute(TargetEvidence {
2499 documents,
2500 crate_shape: supported_shape(),
2501 ..TargetEvidence::default()
2502 });
2503 let mut collided: Vec<&str> = projection
2504 .collisions
2505 .iter()
2506 .map(|Collision { destination, .. }| destination.as_str())
2507 .collect();
2508 collided.sort_unstable();
2509 let mut expected = BLOCK_DESTINATIONS.to_vec();
2510 expected.sort_unstable();
2511 assert_eq!(collided, expected);
2512 for collision in &projection.collisions {
2513 assert!(
2514 collision.reason.contains(&collision.destination),
2515 "{collision:?}"
2516 );
2517 assert!(
2518 !projection
2519 .candidates
2520 .iter()
2521 .any(|candidate| candidate.destination == collision.destination),
2522 "{} collided and still projects",
2523 collision.destination
2524 );
2525 }
2526 let agents = projection
2527 .collisions
2528 .iter()
2529 .find(|c| c.destination == AGENTS_DESTINATION)
2530 .expect("the doubled document collides");
2531 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
2532 let hooks = projection
2533 .collisions
2534 .iter()
2535 .find(|c| c.destination == HOOKS_DESTINATION)
2536 .expect("the unmatched document collides");
2537 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
2538 }
2539}