1pub mod evidence;
30
31use std::collections::BTreeMap;
32
33use serde::{Deserialize, Serialize};
34
35use crate::embedded;
36use crate::error::RkError;
37pub use crate::landing::manifest::Provider;
38use crate::landing::{Params, Workflow};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ProjectionInput {
44 pub params: Params,
46 pub evidence: TargetEvidence,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct TargetEvidence {
54 pub documents: BTreeMap<String, Vec<u8>>,
58 pub crate_shape: CrateShape,
60 pub flake_nix_present: bool,
62 pub flake_lock_present: bool,
64 pub flake_recorded: bool,
67}
68
69impl TargetEvidence {
70 #[must_use]
72 pub fn document(&self, destination: &str) -> Option<&[u8]> {
73 self.documents.get(destination).map(Vec::as_slice)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub struct CrateShape {
81 pub cargo_toml: Option<String>,
83 pub cargo_lock: bool,
85 pub main_rs: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Projection {
96 pub candidates: Vec<Candidate>,
98 pub omissions: Vec<Omission>,
102 pub collisions: Vec<Collision>,
105 pub licence_refusal: Option<String>,
111 pub record_defects: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Candidate {
121 pub destination: String,
123 pub kind: Kind,
125 pub placement: Placement,
127 pub bytes: Vec<u8>,
131 pub region: Option<Vec<u8>>,
134 pub sources: Vec<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Placement {
142 Whole,
144 Region {
147 begin: &'static str,
149 end: &'static str,
151 },
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Omission {
157 pub destination: String,
159 pub reason: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Collision {
166 pub destination: String,
168 pub reason: String,
170}
171
172impl Projection {
173 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
183 Self::compute_over(&embedded_snippets(), input)
184 }
185
186 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
190 let params = &input.params;
191 let evidence = &input.evidence;
192 let mut candidates = Vec::new();
193 for selected in select_pair(files, params.tech(), params.forge())? {
194 if !params.nix() && NIX_DESTINATIONS.contains(&selected.destination.as_str()) {
195 continue;
196 }
197 if !params.scorecard()
198 && SCORECARD_DESTINATIONS.contains(&selected.destination.as_str())
199 {
200 continue;
201 }
202 if code_scanning_withheld(&selected.destination, params.code_scanning()) {
203 continue;
204 }
205 let kind = kind_of(&selected.destination).ok_or_else(|| {
206 anyhow::anyhow!(
207 "the embedded sources do not classify {}; the kind table is stale",
208 selected.destination
209 )
210 })?;
211 let bytes = match kind {
212 Kind::Rendered => render(selected.bytes, params),
213 Kind::Seeded | Kind::State => selected.bytes.to_vec(),
214 };
215 candidates.push(Candidate {
216 destination: selected.destination,
217 kind,
218 placement: Placement::Whole,
219 bytes,
220 region: None,
221 sources: vec![selected.source.to_owned()],
222 });
223 }
224 let mut collisions = Vec::new();
225 for destination in BLOCK_DESTINATIONS {
226 let (template, sources) = block_template(destination, params.workflow())?;
227 if let Some(whole) = candidates
228 .iter()
229 .find(|candidate| candidate.destination == destination)
230 {
231 return Err(anyhow::anyhow!(
232 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
233 whole.sources.join(", "),
234 sources.join(", ")
235 )
236 .into());
237 }
238 let region = render(template.as_bytes(), params);
239 let (begin, end) = block_markers(destination).ok_or_else(|| {
240 anyhow::anyhow!("{destination} is a block destination with no markers")
241 })?;
242 match propose_document(destination, evidence.document(destination), ®ion) {
243 Ok(bytes) => candidates.push(Candidate {
244 destination: destination.to_owned(),
245 kind: Kind::Rendered,
246 placement: Placement::Region { begin, end },
247 bytes,
248 region: Some(region),
249 sources,
250 }),
251 Err(reason) => collisions.push(Collision {
252 destination: destination.to_owned(),
253 reason,
254 }),
255 }
256 }
257 let mut omissions = Vec::new();
258 if let Some((set, reason)) = nix_withholding(params.nix(), evidence) {
259 candidates.retain(|candidate| {
260 if set.contains(&candidate.destination.as_str()) {
261 omissions.push(Omission {
262 destination: candidate.destination.clone(),
263 reason: reason.clone(),
264 });
265 false
266 } else {
267 true
268 }
269 });
270 }
271 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
272 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
273 let licence_refusal = code_scanning_licence_refusal(
274 params.code_scanning(),
275 params.tech(),
276 &evidence.crate_shape,
277 );
278 let record_defects =
279 code_scanning_incompatibility(params.code_scanning(), params.tech(), params.forge())
280 .into_iter()
281 .collect();
282 Ok(Self {
283 candidates,
284 omissions,
285 collisions,
286 licence_refusal,
287 record_defects,
288 })
289 }
290}
291
292fn embedded_snippets() -> Vec<(String, &'static [u8])> {
295 embedded::walk(&embedded::SNIPPETS)
296 .into_iter()
297 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
298 .collect()
299}
300
301pub fn check_pair(tech: &str, forge: &str) -> Result<(), RkError> {
309 select_pair(&embedded_snippets(), tech, forge).map(|_| ())
310}
311
312#[must_use]
315pub fn supported_pairs() -> Vec<(String, String)> {
316 let mut pairs = Vec::new();
317 for (path, _) in embedded_snippets() {
318 let Some(rest) = path.strip_prefix("snippets/") else {
319 continue;
320 };
321 let mut segments = rest.split('/');
322 let (Some(tech), Some(forge), Some(_)) =
323 (segments.next(), segments.next(), segments.next())
324 else {
325 continue;
326 };
327 if tech.starts_with('_') {
328 continue;
329 }
330 let pair = (tech.to_owned(), forge.to_owned());
331 if !pairs.contains(&pair) {
332 pairs.push(pair);
333 }
334 }
335 pairs
336}
337
338#[derive(Debug)]
341pub struct Selected<'a, T> {
342 pub destination: String,
344 pub source: &'a str,
346 pub bytes: &'a T,
348}
349
350pub fn select_pair<'a, T>(
364 files: &'a [(String, T)],
365 tech: &str,
366 forge: &str,
367) -> Result<Vec<Selected<'a, T>>, RkError> {
368 let mut techs: Vec<&str> = Vec::new();
369 for (path, _) in files {
370 if let Some(rest) = path.strip_prefix("snippets/")
371 && let Some((dir, _)) = rest.split_once('/')
372 && !dir.starts_with('_')
373 && !techs.contains(&dir)
374 {
375 techs.push(dir);
376 }
377 }
378 if tech.starts_with('_') || !techs.contains(&tech) {
379 return Err(RkError::Usage(format!(
380 "unknown tech '{tech}'; the bindings are: {}",
381 techs.join(", ")
382 )));
383 }
384 let pair = format!("snippets/{tech}/{forge}/");
385 if !files.iter().any(|(path, _)| path.starts_with(&pair)) {
386 let mut known: Vec<String> = Vec::new();
387 for tech in &techs {
388 let prefix = format!("snippets/{tech}/");
389 for (path, _) in files {
390 if let Some(rest) = path.strip_prefix(&prefix)
391 && let Some((forge, _)) = rest.split_once('/')
392 {
393 let entry = format!("{tech}, {forge}");
394 if !known.contains(&entry) {
395 known.push(entry);
396 }
397 }
398 }
399 }
400 return Err(RkError::Usage(format!(
401 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
402 known.join("; ")
403 )));
404 }
405 let shared = format!("snippets/_shared/{forge}/");
406 let mut out: Vec<Selected<'a, T>> = Vec::new();
407 for zone in [&shared, &pair] {
408 for (path, bytes) in files {
409 let Some(rel) = path.strip_prefix(zone.as_str()) else {
410 continue;
411 };
412 if let Some(existing) = out.iter().find(|selected| selected.destination == rel) {
413 return Err(anyhow::anyhow!(
414 "the shared zone and the pair ({tech}, {forge}) both ship {rel}: {} and {path}; the embedded sources are defective",
415 existing.source
416 )
417 .into());
418 }
419 out.push(Selected {
420 destination: rel.to_owned(),
421 source: path,
422 bytes,
423 });
424 }
425 }
426 Ok(out)
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(rename_all = "lowercase")]
432pub enum Kind {
433 Rendered,
436 Seeded,
439 State,
442}
443
444impl Kind {
445 #[must_use]
447 pub const fn as_str(self) -> &'static str {
448 match self {
449 Self::Rendered => "rendered",
450 Self::Seeded => "seeded",
451 Self::State => "state",
452 }
453 }
454}
455
456const KINDS: [(&str, Kind); 20] = [
462 (".github/workflows/release-plz.yml", Kind::Rendered),
463 (".github/workflows/release-please.yml", Kind::Rendered),
464 (".github/workflows/release.yml", Kind::Rendered),
465 (".github/workflows/pr-title.yml", Kind::Rendered),
466 (".github/workflows/scorecard.yml", Kind::Rendered),
467 (".github/workflows/code-scanning-codeql.yml", Kind::Rendered),
468 (
469 ".github/workflows/code-scanning-semgrep.yml",
470 Kind::Rendered,
471 ),
472 (".gitlab/ci/code-scanning-semgrep.yml", Kind::Rendered),
473 (".gitlab-ci.yml", Kind::Rendered),
474 ("SECURITY.md", Kind::Rendered),
475 (".gitlab/ci/mr-title.yml", Kind::Rendered),
476 ("release-plz.toml", Kind::Seeded),
477 ("dist-workspace.toml", Kind::Seeded),
478 ("release-please-config.json", Kind::Seeded),
479 ("cliff.toml", Kind::Seeded),
480 ("nix/package.nix", Kind::Seeded),
481 ("flake.nix", Kind::Seeded),
482 (".release-please-manifest.json", Kind::State),
483 ("VERSION", Kind::State),
484 ("flake.lock", Kind::State),
485];
486
487pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
501
502pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
508
509pub const SCORECARD_DESTINATIONS: [&str; 1] = [".github/workflows/scorecard.yml"];
524
525pub const CODE_SCANNING_DESTINATIONS: [(&str, Provider); 3] = [
538 (
539 ".github/workflows/code-scanning-codeql.yml",
540 Provider::CodeQl,
541 ),
542 (
543 ".github/workflows/code-scanning-semgrep.yml",
544 Provider::Semgrep,
545 ),
546 (".gitlab/ci/code-scanning-semgrep.yml", Provider::Semgrep),
547];
548
549pub const CODE_SCANNING_TECHS: [&str; 1] = ["rust"];
557
558#[must_use]
570pub fn code_scanning_incompatibility(
571 provider: Option<Provider>,
572 tech: &str,
573 forge: &str,
574) -> Option<String> {
575 let named = provider?;
576 if !CODE_SCANNING_TECHS.contains(&tech) {
577 return Some(format!(
578 "the {tech} binding ships no code scanning workflow; a scanner reads one language, and the bindings that carry one are: {}",
579 CODE_SCANNING_TECHS.join(", ")
580 ));
581 }
582 if named == Provider::CodeQl && forge != "github" {
583 return Some(format!(
584 "codeql is GitHub's own analyzer and the {forge} pair ships no workflow for it; pass --code-scanning semgrep"
585 ));
586 }
587 None
588}
589
590fn code_scanning_withheld(destination: &str, provider: Option<Provider>) -> bool {
593 CODE_SCANNING_DESTINATIONS
594 .iter()
595 .any(|(name, owner)| *name == destination && provider != Some(*owner))
596}
597
598const OSI_APPROVED: [&str; 18] = [
608 "0bsd",
609 "agpl-3.0",
610 "agpl-3.0-only",
611 "agpl-3.0-or-later",
612 "apache-2.0",
613 "bsd-2-clause",
614 "bsd-3-clause",
615 "bsl-1.0",
616 "epl-2.0",
617 "gpl-2.0",
618 "gpl-2.0-only",
619 "gpl-2.0-or-later",
620 "gpl-3.0",
621 "gpl-3.0-only",
622 "gpl-3.0-or-later",
623 "isc",
624 "mit",
625 "mpl-2.0",
626];
627
628const SPDX_EXCEPTIONS: [&str; 86] = [
645 "389-exception",
646 "asterisk-exception",
647 "asterisk-linking-protocols-exception",
648 "autoconf-exception-2.0",
649 "autoconf-exception-3.0",
650 "autoconf-exception-generic",
651 "autoconf-exception-generic-3.0",
652 "autoconf-exception-macro",
653 "bison-exception-1.24",
654 "bison-exception-2.2",
655 "bootloader-exception",
656 "cgal-linking-exception",
657 "classpath-exception-2.0",
658 "classpath-exception-2.0-short",
659 "clisp-exception-2.0",
660 "cryptsetup-openssl-exception",
661 "digia-qt-lgpl-exception-1.1",
662 "digirule-foss-exception",
663 "ecos-exception-2.0",
664 "erlang-otp-linking-exception",
665 "fawkes-runtime-exception",
666 "fltk-exception",
667 "fmt-exception",
668 "font-exception-2.0",
669 "freertos-exception-2.0",
670 "gcc-exception-2.0",
671 "gcc-exception-2.0-note",
672 "gcc-exception-3.1",
673 "gmsh-exception",
674 "gnat-exception",
675 "gnome-examples-exception",
676 "gnu-compiler-exception",
677 "gnu-javamail-exception",
678 "google-patent-webm",
679 "gpl-3.0-389-ds-base-exception",
680 "gpl-3.0-interface-exception",
681 "gpl-3.0-linking-exception",
682 "gpl-3.0-linking-source-exception",
683 "gpl-cc-1.0",
684 "gstreamer-exception-2005",
685 "gstreamer-exception-2008",
686 "harbour-exception",
687 "i2p-gpl-java-exception",
688 "independent-modules-exception",
689 "kicad-libraries-exception",
690 "kvirc-openssl-exception",
691 "lgpl-3.0-linking-exception",
692 "libpri-openh323-exception",
693 "libtool-exception",
694 "linux-syscall-note",
695 "llgpl",
696 "llvm-exception",
697 "lzma-exception",
698 "mif-exception",
699 "mxml-exception",
700 "nokia-qt-exception-1.1",
701 "ocaml-lgpl-linking-exception",
702 "occt-exception-1.0",
703 "openjdk-assembly-exception-1.0",
704 "openvpn-openssl-exception",
705 "pcre2-exception",
706 "polyparse-exception",
707 "ps-or-pdf-font-exception-20170817",
708 "qpl-1.0-inria-2004-exception",
709 "qt-gpl-exception-1.0",
710 "qt-lgpl-exception-1.1",
711 "qwt-exception-1.0",
712 "romic-exception",
713 "rrdtool-floss-exception-2.0",
714 "rsync-linking-exception",
715 "sane-exception",
716 "shl-2.0",
717 "shl-2.1",
718 "simple-library-usage-exception",
719 "spelling-provider-lgpl-exception",
720 "sqlitestudio-openssl-exception",
721 "stunnel-exception",
722 "swi-exception",
723 "swift-exception",
724 "texinfo-exception",
725 "u-boot-exception-2.0",
726 "ubdl-exception",
727 "universal-foss-exception-1.0",
728 "vsftpd-openssl-exception",
729 "wxwindows-exception-3.1",
730 "x11vnc-openssl-exception",
731];
732
733fn listed(list: &[&str], identifier: &str) -> bool {
740 let lowered = identifier.to_ascii_lowercase();
741 list.contains(&lowered.as_str())
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746enum Token<'a> {
747 Open,
749 Close,
751 And,
753 Or,
755 With,
758 Identifier(&'a str),
761}
762
763fn tokenize(expression: &str) -> Option<Vec<Token<'_>>> {
771 let mut tokens = Vec::new();
772 let bytes = expression.as_bytes();
773 let mut at = 0;
774 while at < bytes.len() {
775 let byte = bytes[at];
776 if byte.is_ascii_whitespace() {
777 at += 1;
778 continue;
779 }
780 if byte == b'(' {
781 tokens.push(Token::Open);
782 at += 1;
783 continue;
784 }
785 if byte == b')' {
786 tokens.push(Token::Close);
787 at += 1;
788 continue;
789 }
790 let start = at;
791 while at < bytes.len() {
792 let byte = bytes[at];
793 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+' | b':') {
794 at += 1;
795 } else {
796 break;
797 }
798 }
799 if at == start {
800 return None;
801 }
802 let word = &expression[start..at];
803 tokens.push(match word {
804 "AND" => Token::And,
805 "OR" => Token::Or,
806 "WITH" => Token::With,
807 other => Token::Identifier(other),
808 });
809 }
810 Some(tokens)
811}
812
813struct Spdx<'a> {
821 tokens: &'a [Token<'a>],
822 at: usize,
823}
824
825impl<'a> Spdx<'a> {
826 fn peek(&self) -> Option<Token<'a>> {
828 self.tokens.get(self.at).copied()
829 }
830
831 fn bump(&mut self) -> Option<Token<'a>> {
833 let token = self.peek()?;
834 self.at += 1;
835 Some(token)
836 }
837
838 fn expression(&mut self) -> Option<bool> {
845 let mut approved = self.operand()?;
846 while matches!(self.peek(), Some(Token::And | Token::Or)) {
847 self.bump();
848 approved = self.operand()? && approved;
849 }
850 Some(approved)
851 }
852
853 fn operand(&mut self) -> Option<bool> {
863 match self.bump()? {
864 Token::Open => {
865 let inner = self.expression()?;
866 (self.bump()? == Token::Close).then_some(inner)
867 }
868 Token::Identifier(name) => {
869 let identifier = name.strip_suffix('+').unwrap_or(name);
870 let approved = listed(&OSI_APPROVED, identifier);
871 if self.peek() == Some(Token::With) {
872 self.bump();
873 match self.bump()? {
877 Token::Identifier(exception) if listed(&SPDX_EXCEPTIONS, exception) => {}
878 _ => return None,
879 }
880 }
881 Some(approved)
882 }
883 Token::And | Token::Or | Token::With | Token::Close => None,
884 }
885 }
886}
887
888#[must_use]
896pub fn licence_is_osi_approved(expression: &str) -> bool {
897 let Some(tokens) = tokenize(expression) else {
898 return false;
899 };
900 let mut reader = Spdx {
901 tokens: &tokens,
902 at: 0,
903 };
904 let Some(approved) = reader.expression() else {
905 return false;
906 };
907 approved && reader.at == tokens.len()
908}
909
910#[must_use]
924pub fn code_scanning_licence_refusal(
925 provider: Option<Provider>,
926 tech: &str,
927 shape: &CrateShape,
928) -> Option<String> {
929 if provider != Some(Provider::CodeQl) {
930 return None;
931 }
932 if tech != "rust" {
933 return Some(format!(
934 "the {tech} 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"
935 ));
936 }
937 let fallback = "land --code-scanning semgrep, which carries no licence condition";
938 let Some(text) = shape.cargo_toml.as_deref() else {
939 return Some(format!(
940 "the target has no readable Cargo.toml, so the licence codeql's terms depend on cannot be read; {fallback}"
941 ));
942 };
943 let Ok(table) = text.parse::<toml::Table>() else {
944 return Some(format!(
945 "the target's Cargo.toml does not parse, so the licence codeql's terms depend on cannot be read; {fallback}"
946 ));
947 };
948 let licence = table
949 .get("package")
950 .and_then(toml::Value::as_table)
951 .and_then(|package| package.get("license"))
952 .and_then(toml::Value::as_str);
953 match licence {
954 None => Some(format!(
955 "the target's Cargo.toml declares no license field, and codeql's terms cover an open-source codebase alone; declare one, or {fallback}"
956 )),
957 Some(expression) if !licence_is_osi_approved(expression) => Some(format!(
958 "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}"
959 )),
960 Some(_) => None,
961 }
962}
963
964#[must_use]
967pub fn kind_of(destination: &str) -> Option<Kind> {
968 if BLOCK_DESTINATIONS.contains(&destination) {
969 return Some(Kind::Rendered);
970 }
971 KINDS
972 .iter()
973 .find(|(name, _)| *name == destination)
974 .map(|(_, kind)| *kind)
975}
976
977pub fn destinations() -> impl Iterator<Item = &'static str> {
982 KINDS
983 .iter()
984 .map(|(name, _)| *name)
985 .chain(BLOCK_DESTINATIONS)
986}
987
988pub const OWNER_TOKEN: &[u8] = b"OWNER";
995
996pub const REPO_PLACEHOLDER: &str = "OWNER";
1001
1002pub const REPO_TOKEN: &[u8] = b"RK_REPO";
1004
1005pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
1007
1008pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
1011
1012pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
1016
1017pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
1020
1021pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
1027
1028pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
1039 (
1040 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
1041 b"<!--RK_SECURITY_CONTACT_END-->",
1042 ),
1043 (
1044 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
1045 b"<!--RK_SECURITY_RESPONSE_END-->",
1046 ),
1047 (
1048 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
1049 b"<!--RK_SECURITY_DEADLINE_END-->",
1050 ),
1051];
1052
1053fn acknowledgment(response: &str) -> String {
1056 format!("Maintainers acknowledge a report within {response}.")
1057}
1058
1059const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
1063
1064fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
1067 let contact = (!params.security_contact().is_empty())
1068 .then(|| params.security_contact().as_bytes().to_vec());
1069 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
1070 [
1071 contact,
1072 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
1073 promised.then(|| DISCLOSURE_ONLY.to_vec()),
1074 ]
1075}
1076
1077fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
1083 let ordered = find(baseline, begin)
1084 .zip(find(baseline, end))
1085 .filter(|(start, stop)| stop > start);
1086 let Some((start, stop)) = ordered else {
1087 return baseline.to_vec();
1088 };
1089 let mut out = Vec::with_capacity(baseline.len());
1090 out.extend_from_slice(&baseline[..start]);
1091 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
1092 out.extend_from_slice(&baseline[stop + end.len()..]);
1093 out
1094}
1095
1096#[must_use]
1114pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
1115 let repo = params.repo();
1116 let owner = repo.split('/').next().unwrap_or(repo);
1117 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
1118 if let Some(style) = params.style() {
1119 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
1120 }
1121 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
1122 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
1123 let escaped = params.line_prefix().replace('/', "\\/");
1124 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
1125 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
1126 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
1127 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
1128 out = replace_span(&out, begin, end, value.as_deref());
1129 }
1130 out
1131}
1132
1133#[must_use]
1135pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
1136 let mut out = Vec::with_capacity(baseline.len());
1137 let mut rest = baseline;
1138 while let Some(at) = find(rest, token) {
1139 out.extend_from_slice(&rest[..at]);
1140 out.extend_from_slice(value);
1141 rest = &rest[at + token.len()..];
1142 }
1143 out.extend_from_slice(rest);
1144 out
1145}
1146
1147fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1149 haystack
1150 .windows(needle.len())
1151 .position(|window| window == needle)
1152}
1153
1154pub const AGENTS_DESTINATION: &str = "AGENTS.md";
1156
1157pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
1159
1160pub const BLOCK_END: &str = "<!-- END release-kit -->";
1162
1163pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
1168
1169pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
1171
1172pub const BLOCK_DESTINATIONS: [&str; 3] =
1178 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
1179
1180pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
1182
1183pub const HOOKS_END: &str = "# END release-kit";
1185
1186pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
1190
1191pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
1193
1194pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
1196
1197pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
1199
1200pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
1202
1203pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
1205
1206pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
1208
1209#[must_use]
1211pub const fn routing_line(workflow: Workflow) -> &'static str {
1212 match workflow {
1213 Workflow::Worktree => AGENTS_LINE_WORKTREE,
1214 Workflow::Branches => AGENTS_LINE_BRANCHES,
1215 }
1216}
1217
1218pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
1225 let name = path.strip_prefix("blocks/").unwrap_or(path);
1226 let file = embedded::BLOCKS
1227 .get_file(name)
1228 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
1229 std::str::from_utf8(file.contents())
1230 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
1231}
1232
1233#[must_use]
1237pub fn authored(text: &str) -> &str {
1238 text.strip_suffix('\n').unwrap_or(text)
1239}
1240
1241pub 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[-/].+)$";
1249
1250pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
1260
1261#[must_use]
1268pub fn scope_is_shaped(scope: &str) -> bool {
1269 !scope.is_empty()
1270 && scope.chars().all(|c| {
1271 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
1272 })
1273}
1274
1275#[must_use]
1282pub fn compose_routing(template: &str, line: &str) -> String {
1283 authored(template).replacen("RK_WORKFLOW_LINE", authored(line), 1)
1284}
1285
1286#[must_use]
1290pub fn compose_glossary(template: &str) -> String {
1291 authored(template).to_owned()
1292}
1293
1294#[must_use]
1304pub fn compose_hooks(template: &str, guard: Option<&str>) -> String {
1305 let (guard, skip) = guard.map_or_else(
1306 || (String::new(), "no-commit-to-branch"),
1307 |entry| {
1308 (
1309 format!("{}\n", authored(entry)),
1310 "no-commit-to-branch,rk-worktree-location",
1311 )
1312 },
1313 );
1314 authored(template)
1315 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
1316 .replacen("RK_SWEEP_SKIP", skip, 1)
1317 .replacen("RK_WORKTREE_GUARD", &guard, 1)
1318}
1319
1320pub fn routing_block(workflow: Workflow) -> Result<String, RkError> {
1327 Ok(compose_routing(
1328 embedded_block(AGENTS_BLOCK)?,
1329 embedded_block(routing_line(workflow))?,
1330 ))
1331}
1332
1333pub fn glossary_block() -> Result<String, RkError> {
1339 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
1340}
1341
1342pub fn hooks_block(workflow: Workflow) -> Result<String, RkError> {
1349 let guard = match workflow {
1350 Workflow::Worktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
1351 Workflow::Branches => None,
1352 };
1353 Ok(compose_hooks(embedded_block(PRE_COMMIT_BLOCK)?, guard))
1354}
1355
1356fn block_template(destination: &str, workflow: Workflow) -> Result<(String, Vec<String>), RkError> {
1359 match destination {
1360 AGENTS_DESTINATION => Ok((
1361 routing_block(workflow)?,
1362 vec![AGENTS_BLOCK.to_owned(), routing_line(workflow).to_owned()],
1363 )),
1364 GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
1365 HOOKS_DESTINATION => {
1366 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
1367 if workflow == Workflow::Worktree {
1368 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
1369 }
1370 Ok((hooks_block(workflow)?, sources))
1371 }
1372 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
1373 }
1374}
1375
1376#[must_use]
1378pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
1379 match destination {
1380 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
1381 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
1382 _ => None,
1383 }
1384}
1385
1386#[must_use]
1389pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
1390 let start = text.find(begin)?;
1391 let stop = text[start..].find(end)? + start + end.len();
1392 Some(&text[start..stop])
1393}
1394
1395#[must_use]
1402pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
1403 let block = block.as_bytes();
1404 let Some(text) = existing else {
1405 return [block, b"\n"].concat();
1406 };
1407 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
1411 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
1412 {
1413 let stop = start + offset + BLOCK_END.len();
1414 return [&text[..start], block, &text[stop..]].concat();
1415 }
1416 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
1420 out.extend_from_slice(text);
1421 if !text.ends_with(b"\n") {
1422 out.push(b'\n');
1423 }
1424 out.push(b'\n');
1425 out.extend_from_slice(block);
1426 out.push(b'\n');
1427 out
1428}
1429
1430pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
1443 let Some(text) = existing else {
1444 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
1445 };
1446 if let Some(defect) = hooks_marker_defect(text) {
1447 return Err(defect);
1448 }
1449 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
1450 return Ok(text.replacen(found, block, 1));
1451 }
1452 let mut out = String::with_capacity(text.len() + block.len() + 1);
1453 let mut placed = false;
1454 for line in text.split_inclusive('\n') {
1455 out.push_str(line);
1456 if !placed && line.trim_end() == "repos:" {
1457 if !out.ends_with('\n') {
1458 out.push('\n');
1459 }
1460 out.push_str(block);
1461 out.push('\n');
1462 placed = true;
1463 }
1464 }
1465 if placed {
1466 Ok(out)
1467 } else {
1468 Err(format!(
1469 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
1470 ))
1471 }
1472}
1473
1474#[must_use]
1484pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
1485 let (begin, end) = block_markers(destination)?;
1486 let begins = text.matches(begin).count();
1487 let ends = text.matches(end).count();
1488 if begins > 1 || ends > 1 {
1489 return Some(format!(
1490 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
1491 ));
1492 }
1493 match (text.find(begin), text.find(end)) {
1494 (Some(begin), Some(end)) if end > begin => None,
1495 (None, None) => None,
1496 _ => Some(format!(
1497 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1498 )),
1499 }
1500}
1501
1502#[must_use]
1505pub fn hooks_marker_defect(text: &str) -> Option<String> {
1506 marker_defect(HOOKS_DESTINATION, text)
1507}
1508
1509fn propose_document(
1513 destination: &str,
1514 existing: Option<&[u8]>,
1515 region: &[u8],
1516) -> Result<Vec<u8>, String> {
1517 let block = String::from_utf8_lossy(region).into_owned();
1522 if let Some(text) = existing
1523 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1524 {
1525 return Err(defect);
1526 }
1527 if destination == HOOKS_DESTINATION {
1528 let text = match existing {
1532 None => None,
1533 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1534 format!(
1535 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1536 )
1537 })?),
1538 };
1539 return splice_hooks_block(text, &block).map(String::into_bytes);
1540 }
1541 Ok(splice_marked_block(existing, &block))
1542}
1543
1544#[must_use]
1556pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1557 let Some(text) = shape.cargo_toml.as_deref() else {
1558 return Some(
1559 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1560 );
1561 };
1562 let Ok(table) = text.parse::<toml::Table>() else {
1563 return Some(
1564 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1565 );
1566 };
1567 if !table.contains_key("package") {
1568 return Some(
1569 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1570 );
1571 }
1572 if !shape.cargo_lock {
1573 return Some(
1574 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1575 );
1576 }
1577 let implicit_bin = shape.main_rs
1578 && table
1579 .get("package")
1580 .and_then(toml::Value::as_table)
1581 .and_then(|package| package.get("autobins"))
1582 .and_then(toml::Value::as_bool)
1583 != Some(false);
1584 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1585 if explicit_bins.is_none() && !implicit_bin {
1586 return Some(
1587 "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(),
1588 );
1589 }
1590 if let Some(bins) = explicit_bins {
1596 let required = bins
1597 .first()
1598 .and_then(toml::Value::as_table)
1599 .and_then(|bin| bin.get("required-features"))
1600 .and_then(toml::Value::as_array);
1601 if let Some(required) = required {
1602 let enabled = default_features(&table);
1603 let missing = required
1604 .iter()
1605 .filter_map(toml::Value::as_str)
1606 .any(|feature| !enabled.contains(feature));
1607 if missing {
1608 return Some(
1609 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1610 );
1611 }
1612 }
1613 }
1614 None
1615}
1616
1617fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1620 let edge = format!("dep:{name}");
1621 features.values().any(|list| {
1622 list.as_array().is_some_and(|entries| {
1623 entries
1624 .iter()
1625 .filter_map(toml::Value::as_str)
1626 .any(|entry| entry == edge)
1627 })
1628 })
1629}
1630
1631fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1634 ["dependencies", "build-dependencies"]
1635 .iter()
1636 .any(|section| {
1637 table
1638 .get(*section)
1639 .and_then(toml::Value::as_table)
1640 .and_then(|dependencies| dependencies.get(name))
1641 .and_then(toml::Value::as_table)
1642 .and_then(|dependency| dependency.get("optional"))
1643 .and_then(toml::Value::as_bool)
1644 == Some(true)
1645 })
1646}
1647
1648fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1655 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1656 return std::collections::BTreeSet::new();
1657 };
1658 let mut enabled = std::collections::BTreeSet::new();
1659 let mut queue = vec!["default".to_owned()];
1660 while let Some(name) = queue.pop() {
1661 if !enabled.insert(name.clone()) {
1662 continue;
1663 }
1664 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1665 for implied in implies.iter().filter_map(toml::Value::as_str) {
1666 if implied.starts_with("dep:") || implied.contains("?/") {
1667 continue;
1671 }
1672 if let Some((package, _)) = implied.split_once('/') {
1673 let feature_exists =
1681 features.contains_key(package) || !dep_edge_suppresses(features, package);
1682 if is_optional_dependency(table, package) && feature_exists {
1683 queue.push(package.to_owned());
1684 }
1685 } else {
1686 queue.push(implied.to_owned());
1687 }
1688 }
1689 }
1690 }
1691 enabled
1692}
1693
1694#[must_use]
1702pub fn flake_pair_withheld(
1703 flake_recorded: bool,
1704 flake_nix_present: bool,
1705 flake_lock_present: bool,
1706) -> Option<String> {
1707 if flake_recorded {
1708 return None;
1709 }
1710 let present: Vec<&str> = [
1711 ("flake.nix", flake_nix_present),
1712 ("flake.lock", flake_lock_present),
1713 ]
1714 .into_iter()
1715 .filter_map(|(name, present)| present.then_some(name))
1716 .collect();
1717 if present.is_empty() {
1718 return None;
1719 }
1720 Some(format!(
1721 "the target already carries {}; its flake pair stays its own",
1722 present.join(" and ")
1723 ))
1724}
1725
1726#[must_use]
1736pub fn nix_withholding(
1737 nix: bool,
1738 evidence: &TargetEvidence,
1739) -> Option<(&'static [&'static str], String)> {
1740 if !nix {
1741 return None;
1742 }
1743 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
1744 return Some((&NIX_DESTINATIONS[..], reason));
1745 }
1746 flake_pair_withheld(
1747 evidence.flake_recorded,
1748 evidence.flake_nix_present,
1749 evidence.flake_lock_present,
1750 )
1751 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
1752}
1753
1754#[cfg(test)]
1755mod tests {
1756 use super::{
1757 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
1758 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
1759 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
1760 select_pair,
1761 };
1762 use crate::landing::{Params, Style};
1763
1764 fn supported_shape() -> CrateShape {
1766 CrateShape {
1767 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
1768 cargo_lock: true,
1769 main_rs: true,
1770 }
1771 }
1772
1773 fn input(evidence: TargetEvidence) -> ProjectionInput {
1774 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1775 params.set_nix_for_test(true);
1776 ProjectionInput { params, evidence }
1777 }
1778
1779 fn compute(evidence: TargetEvidence) -> Projection {
1780 Projection::compute(&input(evidence)).expect("the embedded pair projects")
1781 }
1782
1783 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
1784 projection
1785 .candidates
1786 .iter()
1787 .find(|candidate| candidate.destination == destination)
1788 .expect("the destination projects")
1789 }
1790
1791 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
1793 let text = String::from_utf8_lossy(bytes);
1794 let start = text.find(begin).expect("the begin marker is present");
1795 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
1796 (bytes[..start].to_vec(), bytes[stop..].to_vec())
1797 }
1798
1799 #[test]
1800 fn equal_projection_inputs_yield_byte_identical_projections() {
1801 let mut documents = std::collections::BTreeMap::new();
1802 documents.insert(
1803 AGENTS_DESTINATION.to_owned(),
1804 b"# Widget\n\nOwn rules.\n".to_vec(),
1805 );
1806 let evidence = TargetEvidence {
1807 documents,
1808 crate_shape: supported_shape(),
1809 ..TargetEvidence::default()
1810 };
1811 let first = input(evidence.clone());
1812 let second = input(evidence);
1813 assert_eq!(first, second, "the inputs are values and compare equal");
1814 let a = Projection::compute(&first).expect("the pair projects");
1815 let b = Projection::compute(&second).expect("the pair projects");
1816 assert_eq!(a.candidates.len(), b.candidates.len());
1817 for (x, y) in a.candidates.iter().zip(&b.candidates) {
1818 assert_eq!(x.destination, y.destination);
1819 assert_eq!(x.kind, y.kind);
1820 assert_eq!(x.placement, y.placement);
1821 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
1822 assert_eq!(x.region, y.region, "{}", x.destination);
1823 assert_eq!(x.sources, y.sources, "{}", x.destination);
1824 }
1825 assert_eq!(a, b);
1826 let destinations: Vec<&str> = a
1827 .candidates
1828 .iter()
1829 .map(|candidate| candidate.destination.as_str())
1830 .collect();
1831 let mut sorted = destinations.clone();
1832 sorted.sort_unstable();
1833 assert_eq!(destinations, sorted, "candidates sort by destination");
1834 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
1835 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
1836 }
1837
1838 #[test]
1842 fn the_scorecard_destination_projects_only_under_the_opt_in() {
1843 use super::{Kind, SCORECARD_DESTINATIONS, kind_of};
1844 let destination = SCORECARD_DESTINATIONS[0];
1845 assert_eq!(kind_of(destination), Some(Kind::Rendered));
1846
1847 let project = |scorecard: bool| {
1848 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1849 params.set_scorecard_for_test(scorecard);
1850 Projection::compute(&ProjectionInput {
1851 params,
1852 evidence: TargetEvidence::default(),
1853 })
1854 .expect("the embedded pair projects")
1855 };
1856
1857 let off = project(false);
1858 assert!(
1859 !off.candidates
1860 .iter()
1861 .any(|candidate| candidate.destination == destination),
1862 "off by default, and an absent candidate is no omission"
1863 );
1864 assert!(off.omissions.is_empty(), "{:?}", off.omissions);
1865
1866 let on = project(true);
1867 let candidate = candidate(&on, destination);
1868 assert_eq!(candidate.kind, Kind::Rendered);
1869 assert_eq!(candidate.placement, Placement::Whole);
1870 let text = String::from_utf8_lossy(&candidate.bytes);
1871 assert!(!text.contains("RK_"), "a token survived: {text}");
1872 }
1873
1874 #[test]
1878 fn the_licence_judgment_reads_every_operand() {
1879 use super::licence_is_osi_approved as approved;
1880 for expression in [
1881 "MIT",
1882 "Apache-2.0",
1883 "MIT OR Apache-2.0",
1884 "MIT AND Apache-2.0",
1885 "(MIT OR Apache-2.0) AND ISC",
1886 "Apache-2.0 WITH LLVM-exception OR MIT",
1887 "GPL-3.0+",
1888 ] {
1889 assert!(approved(expression), "{expression} is OSI-approved");
1890 }
1891 for expression in [
1892 "",
1893 " ",
1894 "LicenseRef-proprietary",
1895 "MIT AND LicenseRef-proprietary",
1896 "SEE LICENSE IN COPYING",
1897 "CC-BY-4.0",
1898 "DocumentRef-spdx:LicenseRef-proprietary",
1899 ] {
1900 assert!(!approved(expression), "{expression} is not recognized");
1901 }
1902 for expression in [
1907 "MIT OR",
1908 "OR MIT",
1909 "MIT AND",
1910 "MIT WITH",
1911 "WITH LLVM-exception",
1912 "(MIT",
1913 "MIT)",
1914 "MIT Apache-2.0",
1915 "()",
1916 "(MIT OR Apache-2.0",
1917 "MIT OR (Apache-2.0",
1918 "MIT OR ()",
1919 "AND",
1920 "(",
1921 ")",
1922 "MIT WITH AND ISC",
1923 "MIT OR OR ISC",
1924 "MIT @ Apache-2.0",
1925 ] {
1926 assert!(!approved(expression), "{expression} is malformed");
1927 }
1928 for expression in [
1930 "MIT AND (Apache-2.0 OR ISC)",
1931 "((MIT))",
1932 "Apache-2.0 WITH LLVM-exception AND ISC",
1933 "(Apache-2.0 WITH LLVM-exception)",
1934 ] {
1935 assert!(approved(expression), "{expression} is well formed");
1936 }
1937 for expression in [
1942 "mit",
1943 "MiT",
1944 "apache-2.0 OR mit",
1945 "APACHE-2.0 WITH llvm-exception",
1946 ] {
1947 assert!(
1948 approved(expression),
1949 "{expression} names an approved licence"
1950 );
1951 }
1952 for expression in [
1956 "MIT or Apache-2.0",
1957 "MIT and Apache-2.0",
1958 "MIT with LLVM-exception",
1959 ] {
1960 assert!(!approved(expression), "{expression} carries no operator");
1961 }
1962 for expression in [
1966 "MIT WITH definitely-not-an-spdx-exception",
1967 "MIT WITH MIT",
1968 "MIT WITH Apache-2.0",
1969 ] {
1970 assert!(!approved(expression), "{expression} names no exception");
1971 }
1972 for expression in [
1976 "GPL-2.0-only WITH GCC-exception-2.0",
1977 "GPL-2.0-or-later WITH Classpath-exception-2.0",
1978 "Apache-2.0 WITH Swift-exception",
1979 "GPL-3.0-only WITH Autoconf-exception-generic",
1980 ] {
1981 assert!(approved(expression), "{expression} names a real exception");
1982 }
1983 }
1984
1985 #[test]
1990 fn a_recorded_provider_its_pair_cannot_run_is_a_record_defect() {
1991 use super::{Provider, code_scanning_incompatibility};
1992
1993 assert!(code_scanning_incompatibility(None, "bash", "gitlab").is_none());
1994 assert!(code_scanning_incompatibility(Some(Provider::Semgrep), "rust", "gitlab").is_none());
1995 assert!(code_scanning_incompatibility(Some(Provider::CodeQl), "rust", "github").is_none());
1996
1997 let bash = code_scanning_incompatibility(Some(Provider::Semgrep), "bash", "github")
1998 .expect("a binding with no scanner is named");
1999 assert!(bash.contains("bash binding"), "{bash}");
2000 let gitlab = code_scanning_incompatibility(Some(Provider::CodeQl), "rust", "gitlab")
2001 .expect("codeql on gitlab is named");
2002 assert!(gitlab.contains("codeql"), "{gitlab}");
2003
2004 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2007 params.set_code_scanning_for_test(Some(Provider::Semgrep));
2008 let clean = Projection::compute(&ProjectionInput {
2009 params: params.clone(),
2010 evidence: TargetEvidence::default(),
2011 })
2012 .expect("the pair projects");
2013 assert!(
2014 clean.record_defects.is_empty(),
2015 "{:?}",
2016 clean.record_defects
2017 );
2018 }
2019
2020 #[test]
2024 fn the_code_scanning_destinations_follow_the_recorded_provider() {
2025 use super::{
2026 CODE_SCANNING_DESTINATIONS, Kind, Provider, code_scanning_licence_refusal, kind_of,
2027 };
2028 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2029 assert_eq!(kind_of(destination), Some(Kind::Rendered), "{destination}");
2030 }
2031
2032 let project = |provider: Option<Provider>| {
2033 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
2034 params.set_code_scanning_for_test(provider);
2035 Projection::compute(&ProjectionInput {
2036 params,
2037 evidence: TargetEvidence {
2038 crate_shape: CrateShape {
2039 cargo_toml: Some(
2040 "[package]\nname = \"widget\"\nlicense = \"MIT\"\n".to_owned(),
2041 ),
2042 ..CrateShape::default()
2043 },
2044 ..TargetEvidence::default()
2045 },
2046 })
2047 .expect("the embedded pair projects")
2048 };
2049 let landed = |projection: &Projection, destination: &str| {
2050 projection
2051 .candidates
2052 .iter()
2053 .any(|candidate| candidate.destination == destination)
2054 };
2055
2056 let off = project(None);
2057 for (destination, _) in CODE_SCANNING_DESTINATIONS {
2058 assert!(!landed(&off, destination), "{destination}");
2059 }
2060 assert!(off.licence_refusal.is_none());
2061
2062 let codeql = project(Some(Provider::CodeQl));
2063 assert!(landed(
2064 &codeql,
2065 ".github/workflows/code-scanning-codeql.yml"
2066 ));
2067 assert!(!landed(
2068 &codeql,
2069 ".github/workflows/code-scanning-semgrep.yml"
2070 ));
2071 assert!(codeql.licence_refusal.is_none(), "MIT satisfies the terms");
2072
2073 let semgrep = project(Some(Provider::Semgrep));
2074 assert!(landed(
2075 &semgrep,
2076 ".github/workflows/code-scanning-semgrep.yml"
2077 ));
2078 assert!(!landed(
2079 &semgrep,
2080 ".github/workflows/code-scanning-codeql.yml"
2081 ));
2082
2083 let proprietary = CrateShape {
2085 cargo_toml: Some("[package]\nlicense = \"LicenseRef-proprietary\"\n".to_owned()),
2086 ..CrateShape::default()
2087 };
2088 assert!(
2089 code_scanning_licence_refusal(Some(Provider::Semgrep), "rust", &proprietary).is_none()
2090 );
2091 let refusal = code_scanning_licence_refusal(Some(Provider::CodeQl), "rust", &proprietary)
2092 .expect("codeql refuses a licence its terms do not cover");
2093 assert!(refusal.contains("LicenseRef-proprietary"), "{refusal}");
2094 assert!(refusal.contains("semgrep"), "{refusal}");
2095 let bash = code_scanning_licence_refusal(Some(Provider::CodeQl), "bash", &proprietary)
2098 .expect("an unread binding refuses");
2099 assert!(bash.contains("bash binding"), "{bash}");
2100 }
2101
2102 #[test]
2108 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
2109 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
2110 let text = std::fs::read_to_string(&path).expect("the source reads");
2111 let production = text.split("#[cfg(test)]").next().unwrap_or("");
2112 let needles = [
2113 "std::fs",
2114 "std::env",
2115 "std::process",
2116 "std::time",
2117 "SystemTime",
2118 "Instant",
2119 "std::net",
2120 "Command::new",
2121 "registry::",
2122 "curl",
2123 "reqwest",
2124 "blob(",
2125 ];
2126 let mut hits = Vec::new();
2127 for (index, line) in production.lines().enumerate() {
2128 if line.trim_start().starts_with("//") {
2129 continue;
2130 }
2131 for needle in needles {
2132 if line.contains(needle) {
2133 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
2134 }
2135 }
2136 }
2137 assert!(
2138 hits.is_empty(),
2139 "the projection reads beyond its inputs: {hits:?}"
2140 );
2141 }
2142
2143 #[test]
2144 #[allow(
2145 clippy::too_many_lines,
2146 reason = "one test walks the three marked destinations and the three unmarked shapes"
2147 )]
2148 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
2149 let agents_before = "# Widget\n\nOperator prose above.\n\n";
2150 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
2151 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
2152 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
2153 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
2154 let hooks_after =
2155 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2156 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
2157 let mut documents = std::collections::BTreeMap::new();
2158 documents.insert(
2159 AGENTS_DESTINATION.to_owned(),
2160 format!(
2161 "{agents_before}{}{agents_after}",
2162 stale(BLOCK_BEGIN, BLOCK_END)
2163 )
2164 .into_bytes(),
2165 );
2166 documents.insert(
2167 GLOSSARY_DESTINATION.to_owned(),
2168 format!(
2169 "{glossary_before}{}{glossary_after}",
2170 stale(BLOCK_BEGIN, BLOCK_END)
2171 )
2172 .into_bytes(),
2173 );
2174 documents.insert(
2175 HOOKS_DESTINATION.to_owned(),
2176 format!(
2177 "{hooks_before}{}{hooks_after}",
2178 stale(HOOKS_BEGIN, HOOKS_END)
2179 )
2180 .into_bytes(),
2181 );
2182 let projection = compute(TargetEvidence {
2183 documents: documents.clone(),
2184 crate_shape: supported_shape(),
2185 ..TargetEvidence::default()
2186 });
2187 assert!(
2188 projection.collisions.is_empty(),
2189 "{:?}",
2190 projection.collisions
2191 );
2192 for (destination, before, after) in [
2193 (AGENTS_DESTINATION, agents_before, agents_after),
2194 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
2195 (HOOKS_DESTINATION, hooks_before, hooks_after),
2196 ] {
2197 let candidate = candidate(&projection, destination);
2198 let Placement::Region { begin, end } = candidate.placement else {
2199 panic!("{destination} is a region");
2200 };
2201 let region = candidate
2202 .region
2203 .as_deref()
2204 .expect("a region carries its block");
2205 let (head, tail) = outside(&candidate.bytes, begin, end);
2206 assert_eq!(
2207 head,
2208 before.as_bytes(),
2209 "{destination}: bytes before the markers"
2210 );
2211 assert_eq!(
2212 tail,
2213 after.as_bytes(),
2214 "{destination}: bytes after the markers"
2215 );
2216 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
2217 assert_eq!(
2218 inside, region,
2219 "{destination}: the region is the rendered block"
2220 );
2221 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
2222 assert_eq!(head, existing_head);
2223 assert_eq!(tail, existing_tail);
2224 }
2225
2226 let own_hooks =
2230 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
2231 let own_agents = "# Widget\n\nOwn rules.";
2232 let mut documents = std::collections::BTreeMap::new();
2233 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
2234 documents.insert(
2235 AGENTS_DESTINATION.to_owned(),
2236 own_agents.as_bytes().to_vec(),
2237 );
2238 let projection = compute(TargetEvidence {
2239 documents,
2240 crate_shape: supported_shape(),
2241 ..TargetEvidence::default()
2242 });
2243 assert!(
2244 projection.collisions.is_empty(),
2245 "{:?}",
2246 projection.collisions
2247 );
2248 let hooks = candidate(&projection, HOOKS_DESTINATION);
2249 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
2250 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
2251 assert!(
2252 hooks_text.starts_with(&format!(
2253 "repos:\n{region}\n - repo: https://example.com/own"
2254 )),
2255 "{hooks_text}"
2256 );
2257 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
2258 let agents = candidate(&projection, AGENTS_DESTINATION);
2259 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
2260 assert_eq!(
2261 extract_block(
2262 &String::from_utf8_lossy(&agents.bytes),
2263 BLOCK_BEGIN,
2264 BLOCK_END
2265 )
2266 .map(str::as_bytes),
2267 agents.region.as_deref()
2268 );
2269 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
2270 let region = glossary.region.as_deref().expect("a region");
2271 assert_eq!(
2272 glossary.bytes,
2273 [region, b"\n"].concat(),
2274 "an absent file is fresh"
2275 );
2276 }
2277
2278 #[test]
2284 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
2285 let mut documents = std::collections::BTreeMap::new();
2286 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
2287 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
2288 invalid.extend_from_slice(b" - repo: local \xff below\n");
2289 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
2290 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
2291 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
2292 agents.extend_from_slice(b"r\xe9sum\xe9\n");
2293 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
2294 let projection = compute(TargetEvidence {
2295 documents,
2296 crate_shape: supported_shape(),
2297 ..TargetEvidence::default()
2298 });
2299 let collided: Vec<&str> = projection
2300 .collisions
2301 .iter()
2302 .map(|c| c.destination.as_str())
2303 .collect();
2304 assert_eq!(collided, [HOOKS_DESTINATION]);
2305 assert!(
2306 projection.collisions[0].reason.contains("not UTF-8"),
2307 "{}",
2308 projection.collisions[0].reason
2309 );
2310 assert!(
2311 !projection
2312 .candidates
2313 .iter()
2314 .any(|c| c.destination == HOOKS_DESTINATION),
2315 "a colliding destination projects no candidate"
2316 );
2317 let agents = candidate(&projection, AGENTS_DESTINATION);
2318 assert!(
2319 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
2320 "{:?}",
2321 agents.bytes
2322 );
2323 assert!(
2324 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
2325 "{:?}",
2326 agents.bytes
2327 );
2328 assert!(
2329 !agents.bytes.contains(&0xEF),
2330 "a replacement character landed"
2331 );
2332
2333 let mut documents = std::collections::BTreeMap::new();
2334 let valid =
2335 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
2336 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
2337 let projection = compute(TargetEvidence {
2338 documents,
2339 crate_shape: supported_shape(),
2340 ..TargetEvidence::default()
2341 });
2342 assert!(
2343 projection.collisions.is_empty(),
2344 "{:?}",
2345 projection.collisions
2346 );
2347 let hooks = candidate(&projection, HOOKS_DESTINATION);
2348 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
2349 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
2350 assert!(text.ends_with("\n - repo: local\n"), "{text}");
2351 assert!(!text.contains("stale"), "the region is replaced: {text}");
2352 }
2353
2354 #[test]
2358 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
2359 let files: Vec<(String, &[u8])> = vec![
2360 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
2361 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
2362 ];
2363 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
2364 .expect_err("a whole file at a block destination refuses");
2365 let text = err.to_string();
2366 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
2367 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
2368 assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
2369 assert!(text.contains("embedded sources are defective"), "{text}");
2370 }
2371
2372 #[test]
2373 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
2374 {
2375 let files: Vec<(String, &[u8])> = vec![
2376 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2377 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
2378 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2379 ];
2380 let err = select_pair(&files, "rust", "github").expect_err("a doubled destination refuses");
2381 let text = err.to_string();
2382 assert!(
2383 text.contains("snippets/_shared/github/SECURITY.md"),
2384 "{text}"
2385 );
2386 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
2387 assert!(text.contains("embedded sources are defective"), "{text}");
2388
2389 let clean: Vec<(String, &[u8])> = vec![
2390 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
2391 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
2392 ];
2393 let selected = select_pair(&clean, "rust", "github").expect("a clean list selects");
2394 let destinations: Vec<&str> = selected.iter().map(|s| s.destination.as_str()).collect();
2395 assert_eq!(destinations, ["SECURITY.md", "release-plz.toml"]);
2396 assert_eq!(selected[0].source, "snippets/_shared/github/SECURITY.md");
2397 let err = select_pair(&clean, "_shared", "github").expect_err("the shared zone is no tech");
2398 assert!(!err.to_string().contains("bindings are: _shared"), "{err}");
2399 let err = select_pair(&clean, "rust", "gitlab").expect_err("an unshipped pair refuses");
2400 assert!(err.to_string().contains("rust, github"), "{err}");
2401
2402 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
2403 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
2404 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
2405 let mut documents = std::collections::BTreeMap::new();
2406 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
2407 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
2408 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
2409 let projection = compute(TargetEvidence {
2410 documents,
2411 crate_shape: supported_shape(),
2412 ..TargetEvidence::default()
2413 });
2414 let mut collided: Vec<&str> = projection
2415 .collisions
2416 .iter()
2417 .map(|Collision { destination, .. }| destination.as_str())
2418 .collect();
2419 collided.sort_unstable();
2420 let mut expected = BLOCK_DESTINATIONS.to_vec();
2421 expected.sort_unstable();
2422 assert_eq!(collided, expected);
2423 for collision in &projection.collisions {
2424 assert!(
2425 collision.reason.contains(&collision.destination),
2426 "{collision:?}"
2427 );
2428 assert!(
2429 !projection
2430 .candidates
2431 .iter()
2432 .any(|candidate| candidate.destination == collision.destination),
2433 "{} collided and still projects",
2434 collision.destination
2435 );
2436 }
2437 let agents = projection
2438 .collisions
2439 .iter()
2440 .find(|c| c.destination == AGENTS_DESTINATION)
2441 .expect("the doubled document collides");
2442 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
2443 let hooks = projection
2444 .collisions
2445 .iter()
2446 .find(|c| c.destination == HOOKS_DESTINATION)
2447 .expect("the unmatched document collides");
2448 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
2449 }
2450}