Skip to content

g1t/services/security/src/config.rs

1,483 lines70,681 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! The dependency update file, `dependabot.yml` (version 2): every option
2//! of its format read and checked, each problem reported with its line and
3//! key. A file with problems is shown but not acted on, so a mistake never
4//! opens pull requests nobody asked for.
5//!
6//! Every `package-ecosystem` the format names is accepted; those g1t does
7//! not update yet are read, checked and listed as such (see [`SUPPORTED`]).
8//! Unknown keys are problems, as the format has it.
9
10use g1t_contracts::updates::{ConfigProblem, UpdateRegistry, UpdateAllow, UpdateGroup, UpdateIgnore};
11
12use crate::schedule::{self, Interval, Schedule, WEEKDAYS};
13use crate::timezones;
14use crate::yaml::{self, Node, Value};
15
16/// Every `package-ecosystem` value.
17pub const ECOSYSTEMS: [&str; 33] = [
18 "bazel",
19 "bun",
20 "bundler",
21 "cargo",
22 "composer",
23 "conda",
24 "deno",
25 "devcontainers",
26 "docker",
27 "docker-compose",
28 "dotnet-sdk",
29 "elm",
30 "github-actions",
31 "gitsubmodule",
32 "gomod",
33 "gradle",
34 "helm",
35 "julia",
36 "maven",
37 "mix",
38 "nix",
39 "npm",
40 "nuget",
41 "opentofu",
42 "pip",
43 "pre-commit",
44 "pub",
45 "rust-toolchain",
46 "sbt",
47 "swift",
48 "terraform",
49 "uv",
50 "vcpkg",
51];
52
53/// The ecosystems g1t opens version update pull requests for: those whose
54/// lockfiles it reads and its update sandbox can change.
55pub const SUPPORTED: [&str; 4] = ["npm", "cargo", "gomod", "pip"];
56
57const ROOT_KEYS: [&str; 5] = ["version", "updates", "registries", "enable-beta-ecosystems", "multi-ecosystem-groups"];
58const ENTRY_KEYS: [&str; 26] = [
59 "package-ecosystem",
60 "directory",
61 "directories",
62 "schedule",
63 "allow",
64 "ignore",
65 "groups",
66 "cooldown",
67 "assignees",
68 "reviewers",
69 "labels",
70 "milestone",
71 "commit-message",
72 "open-pull-requests-limit",
73 "pull-request-branch-name",
74 "rebase-strategy",
75 "target-branch",
76 "vendor",
77 "versioning-strategy",
78 "insecure-external-code-execution",
79 "exclude-paths",
80 "registries",
81 "patterns",
82 "multi-ecosystem-group",
83 "name",
84 "enable-beta-ecosystems",
85];
86const SCHEDULE_KEYS: [&str; 5] = ["interval", "day", "time", "timezone", "cronjob"];
87const GROUP_KEYS: [&str; 6] = ["applies-to", "dependency-type", "patterns", "exclude-patterns", "update-types", "group-by"];
88const COOLDOWN_KEYS: [&str; 6] = ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days", "include", "exclude"];
89const BRANCH_KEYS: [&str; 6] = ["separator", "prefix", "max-length", "word-separator", "branch-name-case", "template"];
90const MULTI_GROUP_KEYS: [&str; 11] = [
91 "schedule",
92 "labels",
93 "assignees",
94 "milestone",
95 "target-branch",
96 "commit-message",
97 "pull-request-branch-name",
98 "open-pull-requests-limit",
99 "update-types",
100 "dependency-type",
101 "exclude-patterns",
102];
103const REGISTRY_TYPES: [&str; 15] = [
104 "cargo-registry",
105 "composer-repository",
106 "docker-registry",
107 "git",
108 "goproxy-server",
109 "helm-registry",
110 "hex-organization",
111 "hex-repository",
112 "maven-repository",
113 "npm-registry",
114 "nuget-feed",
115 "pub-repository",
116 "python-index",
117 "rubygems-server",
118 "terraform-registry",
119];
120const REGISTRY_KEYS: [&str; 21] = [
121 "type",
122 "url",
123 "username",
124 "password",
125 "key",
126 "token",
127 "replaces-base",
128 "scope",
129 "organization",
130 "repo",
131 "auth-key",
132 "public-key-fingerprint",
133 "registry",
134 "tenant-id",
135 "client-id",
136 "jfrog-oidc-provider-name",
137 "identity-mapping-name",
138 "audience",
139 "aws-region",
140 "account-id",
141 "role-name",
142];
143/// Keys of a registry that also take AWS CodeArtifact's settings.
144const REGISTRY_MORE_KEYS: [&str; 2] = ["domain", "domain-owner"];
145const SEMVER_UPDATE_TYPES: [&str; 3] = ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"];
146const LEVELS: [&str; 3] = ["major", "minor", "patch"];
147const DEPENDENCY_TYPES: [&str; 5] = ["direct", "indirect", "all", "production", "development"];
148const STRATEGIES: [&str; 5] = ["auto", "increase", "increase-if-necessary", "lockfile-only", "widen"];
149const TEMPLATE_PLACEHOLDERS: [&str; 8] =
150 ["prefix", "package_manager", "directory", "target_branch", "dependency", "version", "group_name", "name"];
151/// The most entries one file may hold.
152const MAX_ENTRIES: usize = 200;
153const MAX_REGISTRIES: usize = 100;
154
155/// `commit-message`.
156#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub struct CommitMessage {
158 pub prefix: Option<String>,
159 pub prefix_development: Option<String>,
160 /// `include: scope`.
161 pub scope: bool,
162}
163
164/// `pull-request-branch-name`.
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct BranchName {
167 pub separator: String,
168 pub prefix: Option<String>,
169 pub max_length: Option<u32>,
170 pub word_separator: Option<String>,
171 /// `lowercase` or `uppercase`.
172 pub case: Option<String>,
173 pub template: Option<String>,
174}
175
176impl Default for BranchName {
177 fn default() -> Self {
178 BranchName { separator: "/".to_owned(), prefix: None, max_length: None, word_separator: None, case: None, template: None }
179 }
180}
181
182/// `cooldown`, in days.
183#[derive(Clone, Debug, Default, PartialEq, Eq)]
184pub struct Cooldown {
185 pub default_days: Option<u32>,
186 pub major_days: Option<u32>,
187 pub minor_days: Option<u32>,
188 pub patch_days: Option<u32>,
189 pub include: Vec<String>,
190 pub exclude: Vec<String>,
191}
192
193/// A top-level `registries` entry. Credentials are kept as written, with
194/// `${{secrets.NAME}}` unresolved until an update needs them.
195#[derive(Clone, Debug, Default, PartialEq, Eq)]
196pub struct Registry {
197 pub name: String,
198 pub kind: String,
199 pub url: String,
200 pub username: Option<String>,
201 pub password: Option<String>,
202 pub token: Option<String>,
203 pub key: Option<String>,
204 pub replaces_base: bool,
205 pub scopes: Vec<String>,
206 /// Whether it signs in with OIDC (`tenant-id`, `jfrog-oidc-provider-name`,
207 /// `aws-region`…), which g1t has no identity for.
208 pub oidc: bool,
209}
210
211/// The names `${{secrets.NAME}}` refers to in `text`.
212pub fn secret_names(text: &str) -> Vec<String> {
213 let mut names = Vec::new();
214 let mut rest = text;
215 while let Some(start) = rest.find("${{") {
216 let after = &rest[start + 3..];
217 let Some(end) = after.find("}}") else { break };
218 let inner = after[..end].trim();
219 if let Some(name) = inner.strip_prefix("secrets.") {
220 names.push(name.trim().to_owned());
221 }
222 rest = &after[end + 2..];
223 }
224 names
225}
226
227impl Registry {
228 pub fn info(&self) -> UpdateRegistry {
229 let mut secrets: Vec<String> = [&self.username, &self.password, &self.token, &self.key]
230 .into_iter()
231 .flatten()
232 .flat_map(|text| secret_names(text))
233 .collect();
234 secrets.sort();
235 secrets.dedup();
236 UpdateRegistry { name: self.name.clone(), kind: self.kind.clone(), url: self.url.clone(), secrets }
237 }
238}
239
240/// A `multi-ecosystem-groups` entry.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct MultiGroup {
243 pub name: String,
244 pub schedule: Option<Schedule>,
245 pub labels: Option<Vec<String>>,
246 pub assignees: Vec<String>,
247 pub milestone: Option<u32>,
248 pub target_branch: Option<String>,
249 pub commit_message: Option<CommitMessage>,
250 pub branch_name: Option<BranchName>,
251 pub open_pull_requests_limit: Option<u32>,
252}
253
254/// One `updates` entry, read and checked.
255#[derive(Clone, Debug, PartialEq)]
256pub struct Entry {
257 /// Where it starts in the file.
258 pub line: u32,
259 pub ecosystem: String,
260 /// From the repository's root, each starting with `/`. May hold globs
261 /// when written as `directories`.
262 pub directories: Vec<String>,
263 /// Its own schedule, or its multi-ecosystem group's.
264 pub schedule: Option<Schedule>,
265 pub allow: Vec<UpdateAllow>,
266 pub ignore: Vec<UpdateIgnore>,
267 pub groups: Vec<UpdateGroup>,
268 pub cooldown: Option<Cooldown>,
269 pub assignees: Vec<String>,
270 pub reviewers: Vec<String>,
271 /// None for the default labels.
272 pub labels: Option<Vec<String>>,
273 pub milestone: Option<u32>,
274 pub commit_message: Option<CommitMessage>,
275 pub open_pull_requests_limit: u32,
276 pub branch_name: BranchName,
277 /// `auto` or `disabled`.
278 pub rebase_strategy: String,
279 pub target_branch: Option<String>,
280 pub vendor: bool,
281 pub versioning_strategy: Option<String>,
282 pub insecure_external_code_execution: Option<String>,
283 pub exclude_paths: Vec<String>,
284 /// The registries it may use, by name (`*` already expanded).
285 pub registries: Vec<String>,
286 pub patterns: Vec<String>,
287 pub multi_ecosystem_group: Option<String>,
288 pub name: Option<String>,
289 /// As written, for showing.
290 pub options: serde_json::Value,
291}
292
293impl Entry {
294 /// [`g1t_contracts::updates::VersionUpdateEntry::id`].
295 pub fn id(&self) -> String {
296 let target = self.target_branch.as_deref().map(|branch| format!("@{branch}")).unwrap_or_default();
297 format!("{}:{}{target}", self.ecosystem, self.directories.join(","))
298 }
299
300 pub fn supported(&self) -> bool {
301 SUPPORTED.contains(&self.ecosystem.as_str())
302 }
303}
304
305/// The whole file.
306#[derive(Clone, Debug, Default, PartialEq)]
307pub struct Config {
308 pub updates: Vec<Entry>,
309 pub registries: Vec<Registry>,
310 pub multi_groups: Vec<MultiGroup>,
311 pub beta: bool,
312}
313
314/// What reading the file found: what it says, as far as it could be read,
315/// and every problem. Only a file without problems is acted on.
316#[derive(Debug, Default)]
317pub struct Read {
318 pub config: Config,
319 pub problems: Vec<ConfigProblem>,
320}
321
322struct Reader {
323 problems: Vec<ConfigProblem>,
324}
325
326fn quote(list: &[&str]) -> String {
327 match list {
328 [] => String::new(),
329 [one] => format!("`{one}`"),
330 [rest @ .., last] => format!("{} or `{last}`", rest.iter().map(|item| format!("`{item}`")).collect::<Vec<_>>().join(", ")),
331 }
332}
333
334impl Reader {
335 fn problem(&mut self, node: &Node, key: &str, message: impl Into<String>) {
336 self.problems.push(ConfigProblem { line: node.line, column: node.column, key: key.to_owned(), message: message.into() });
337 }
338
339 /// Each key of `node` must be one of `allowed`.
340 fn keys(&mut self, node: &Node, path: &str, allowed: &[&str], what: &str) {
341 for (key, _) in node.as_map().unwrap_or_default() {
342 let name = key.scalar().unwrap_or_default();
343 if !allowed.contains(&name.as_str()) {
344 self.problem(key, path, format!("`{name}` is not an option of {what}."));
345 }
346 }
347 }
348
349 fn map<'a>(&mut self, node: &'a Node, path: &str) -> Option<&'a [(Node, Node)]> {
350 let entries = node.as_map();
351 if entries.is_none() {
352 self.problem(node, path, format!("This must be a mapping of keys to values, not {}.", node.kind()));
353 }
354 entries
355 }
356
357 fn text(&mut self, node: &Node, path: &str) -> Option<String> {
358 match &node.value {
359 Value::Text { text, .. } => Some(text.clone()),
360 Value::Int(_) | Value::Float(_) => node.scalar(),
361 _ => {
362 self.problem(node, path, format!("This must be text, not {}.", node.kind()));
363 None
364 }
365 }
366 }
367
368 fn nonempty(&mut self, node: &Node, path: &str) -> Option<String> {
369 let text = self.text(node, path)?;
370 if text.trim().is_empty() {
371 self.problem(node, path, "This must not be empty.");
372 return None;
373 }
374 Some(text)
375 }
376
377 fn one_of(&mut self, node: &Node, path: &str, allowed: &[&str], what: &str) -> Option<String> {
378 let text = self.text(node, path)?;
379 if !allowed.contains(&text.as_str()) {
380 self.problem(node, path, format!("`{text}` is not {what}. Use {}.", quote(allowed)));
381 return None;
382 }
383 Some(text)
384 }
385
386 fn boolean(&mut self, node: &Node, path: &str) -> Option<bool> {
387 match node.value {
388 Value::Bool(value) => Some(value),
389 _ => {
390 self.problem(node, path, format!("This must be true or false, not {}.", node.kind()));
391 None
392 }
393 }
394 }
395
396 fn integer(&mut self, node: &Node, path: &str, low: i64, high: Option<i64>) -> Option<u32> {
397 let range = match high {
398 Some(high) => format!("from {low} to {high}"),
399 None => format!("of {low} or more"),
400 };
401 match node.value {
402 Value::Int(n) if n >= low && high.is_none_or(|high| n <= high) && n <= i64::from(u32::MAX) => Some(n as u32),
403 _ => {
404 self.problem(node, path, format!("This must be a whole number {range}."));
405 None
406 }
407 }
408 }
409
410 /// A list of distinct, non-empty strings. `min` items at least.
411 fn strings(&mut self, node: &Node, path: &str, min: usize) -> Option<Vec<String>> {
412 let Value::Seq(items) = &node.value else {
413 self.problem(node, path, format!("This must be a list, not {}.", node.kind()));
414 return None;
415 };
416 let mut out: Vec<String> = Vec::new();
417 for (index, item) in items.iter().enumerate() {
418 let at = format!("{path}[{index}]");
419 let Some(text) = self.nonempty(item, &at) else { continue };
420 if out.contains(&text) {
421 self.problem(item, &at, format!("`{text}` is listed twice."));
422 continue;
423 }
424 out.push(text);
425 }
426 if out.len() < min {
427 self.problem(node, path, if min == 1 { "This list must not be empty.".to_owned() } else { format!("This list needs at least {min} items.") });
428 }
429 Some(out)
430 }
431
432 fn update_types(&mut self, node: &Node, path: &str, allowed: &[&str]) -> Vec<String> {
433 let found = self.strings(node, path, 1).unwrap_or_default();
434 let Value::Seq(items) = &node.value else { return Vec::new() };
435 let mut out = Vec::new();
436 for (item, text) in items.iter().zip(&found) {
437 if allowed.contains(&text.as_str()) {
438 out.push(text.clone());
439 } else {
440 self.problem(item, path, format!("`{text}` is not an update type. Use {}.", quote(allowed)));
441 }
442 }
443 out
444 }
445
446 fn schedule(&mut self, node: &Node, path: &str) -> Option<Schedule> {
447 self.map(node, path)?;
448 self.keys(node, path, &SCHEDULE_KEYS, "`schedule`");
449 let Some(interval_node) = node.get("interval") else {
450 self.problem(node, path, "`interval` is required: daily, weekly, monthly, quarterly, semiannually, yearly or cron.");
451 return None;
452 };
453 let names: Vec<&str> = Interval::ALL.iter().map(|interval| interval.as_str()).collect();
454 let interval = self.one_of(interval_node, &format!("{path}.interval"), &names, "an interval");
455 let interval = interval.and_then(|text| Interval::parse(&text));
456 let day = node.get("day").and_then(|day| {
457 let text = self.one_of(day, &format!("{path}.day"), &WEEKDAYS, "a day of the week")?;
458 WEEKDAYS.iter().position(|name| *name == text).map(|at| at as u32)
459 });
460 let time = node.get("time").and_then(|time| {
461 let text = self.text(time, &format!("{path}.time"))?;
462 let parsed = schedule::parse_time(&text);
463 if parsed.is_none() {
464 self.problem(time, &format!("{path}.time"), format!("`{text}` is not a time. Write it as hh:mm, such as \"09:00\"."));
465 }
466 parsed
467 });
468 let timezone = node.get("timezone").and_then(|zone| {
469 let text = self.text(zone, &format!("{path}.timezone"))?;
470 if !timezones::known(&text) {
471 self.problem(zone, &format!("{path}.timezone"), format!("`{text}` is not a time zone. Use a name from the IANA database, such as \"America/New_York\"."));
472 return None;
473 }
474 Some(text)
475 });
476 let cron = match (interval, node.get("cronjob")) {
477 (Some(Interval::Cron), Some(job)) => {
478 let text = self.text(job, &format!("{path}.cronjob"))?;
479 match schedule::cronjob(&text) {
480 Ok(cron) => Some(cron),
481 Err(message) => {
482 self.problem(job, &format!("{path}.cronjob"), message);
483 None
484 }
485 }
486 }
487 (Some(Interval::Cron), None) => {
488 self.problem(node, path, "`cronjob` is required when the interval is cron, such as cronjob: \"0 9 * * 1\".");
489 None
490 }
491 // Only read for cron; another interval leaves it unread.
492 _ => None,
493 };
494 Some(Schedule { interval: interval?, day, time, timezone, cron })
495 }
496
497 fn commit_message(&mut self, node: &Node, path: &str) -> Option<CommitMessage> {
498 self.map(node, path)?;
499 self.keys(node, path, &["prefix", "prefix-development", "include"], "`commit-message`");
500 let mut message = CommitMessage::default();
501 let prefix = |reader: &mut Reader, key: &str| -> Option<String> {
502 let found = node.get(key)?;
503 let text = reader.text(found, &format!("{path}.{key}"))?;
504 if text.chars().count() > 50 {
505 reader.problem(found, &format!("{path}.{key}"), "A prefix is at most 50 characters.");
506 return None;
507 }
508 Some(text)
509 };
510 message.prefix = prefix(self, "prefix");
511 message.prefix_development = prefix(self, "prefix-development");
512 if let Some(include) = node.get("include") {
513 message.scope = self.one_of(include, &format!("{path}.include"), &["scope"], "something `include` takes").is_some();
514 }
515 if node.as_map().is_some_and(<[_]>::is_empty) {
516 self.problem(node, path, "`commit-message` needs prefix, prefix-development or include.");
517 }
518 Some(message)
519 }
520
521 fn branch_name(&mut self, node: &Node, path: &str) -> Option<BranchName> {
522 self.map(node, path)?;
523 self.keys(node, path, &BRANCH_KEYS, "`pull-request-branch-name`");
524 let mut name = BranchName::default();
525 if let Some(separator) = node.get("separator") {
526 name.separator = self.one_of(separator, &format!("{path}.separator"), &["-", "_", "/"], "a separator").unwrap_or(name.separator);
527 }
528 if let Some(prefix) = node.get("prefix") {
529 name.prefix = self.nonempty(prefix, &format!("{path}.prefix")).filter(|text| {
530 let ok = text.chars().count() <= 50 && valid_ref_part(text);
531 if !ok {
532 self.problem(prefix, &format!("{path}.prefix"), "A prefix is at most 50 characters and must be usable in a branch name.");
533 }
534 ok
535 });
536 }
537 if let Some(length) = node.get("max-length") {
538 name.max_length = self.integer(length, &format!("{path}.max-length"), 20, Some(244));
539 }
540 if let Some(separator) = node.get("word-separator") {
541 name.word_separator = self.one_of(separator, &format!("{path}.word-separator"), &["-", "_", "/", "."], "a word separator");
542 }
543 if let Some(case) = node.get("branch-name-case") {
544 name.case = self.one_of(case, &format!("{path}.branch-name-case"), &["lowercase", "uppercase"], "a case");
545 }
546 if let Some(template) = node.get("template") {
547 name.template = self.text(template, &format!("{path}.template")).filter(|text| {
548 match template_problem(text) {
549 Some(message) => {
550 self.problem(template, &format!("{path}.template"), message);
551 false
552 }
553 None => true,
554 }
555 });
556 }
557 if node.as_map().is_some_and(<[_]>::is_empty) {
558 self.problem(node, path, "`pull-request-branch-name` needs at least one option, such as separator.");
559 }
560 Some(name)
561 }
562
563 fn directory(&mut self, node: &Node, path: &str, globs: bool) -> Option<String> {
564 let raw = self.nonempty(node, path)?;
565 let trimmed = raw.trim().trim_end_matches('/');
566 if trimmed.split('/').any(|part| part == "..") {
567 self.problem(node, path, format!("`{raw}` must stay inside the repository."));
568 return None;
569 }
570 if !globs && (trimmed.contains('*') || trimmed.contains('?')) {
571 self.problem(node, path, format!("`{raw}` has a wildcard; use `directories` for globs."));
572 return None;
573 }
574 Some(if trimmed.is_empty() || trimmed == "." {
575 "/".to_owned()
576 } else if trimmed.starts_with('/') {
577 trimmed.to_owned()
578 } else {
579 format!("/{}", trimmed.trim_start_matches("./"))
580 })
581 }
582
583 fn groups(&mut self, node: &Node, path: &str) -> Vec<UpdateGroup> {
584 let Some(entries) = self.map(node, path) else { return Vec::new() };
585 if entries.is_empty() {
586 self.problem(node, path, "`groups` must name at least one group.");
587 }
588 let mut groups = Vec::new();
589 for (key, group) in entries {
590 let name = key.scalar().unwrap_or_default();
591 let at = format!("{path}.{name}");
592 if !valid_group_name(&name) {
593 self.problem(key, &at, "A group's name starts and ends with a letter or digit, and holds only letters, digits, `|`, `_` and `-`.");
594 }
595 if self.map(group, &at).is_none() {
596 continue;
597 }
598 self.keys(group, &at, &GROUP_KEYS, "a group");
599 let applies_to = group
600 .get("applies-to")
601 .and_then(|value| self.one_of(value, &format!("{at}.applies-to"), &["version-updates", "security-updates"], "what a group applies to"))
602 .unwrap_or_else(|| "version-updates".to_owned());
603 let dependency_type = group
604 .get("dependency-type")
605 .and_then(|value| self.one_of(value, &format!("{at}.dependency-type"), &["development", "production"], "a dependency type"));
606 let patterns = group.get("patterns").and_then(|value| self.strings(value, &format!("{at}.patterns"), 1)).unwrap_or_default();
607 let exclude_patterns =
608 group.get("exclude-patterns").and_then(|value| self.strings(value, &format!("{at}.exclude-patterns"), 1)).unwrap_or_default();
609 let update_types = group.get("update-types").map(|value| self.update_types(value, &format!("{at}.update-types"), &LEVELS)).unwrap_or_default();
610 let group_by = group
611 .get("group-by")
612 .and_then(|value| self.one_of(value, &format!("{at}.group-by"), &["dependency-name"], "something `group-by` takes"));
613 if group_by.is_some() && applies_to == "security-updates" {
614 self.problem(group, &at, "`group-by` applies to version updates only.");
615 }
616 groups.push(UpdateGroup { name, applies_to, patterns, exclude_patterns, update_types, dependency_type, group_by });
617 }
618 groups
619 }
620
621 fn cooldown(&mut self, node: &Node, path: &str) -> Option<Cooldown> {
622 self.map(node, path)?;
623 self.keys(node, path, &COOLDOWN_KEYS, "`cooldown`");
624 let days = |reader: &mut Reader, key: &str, low: i64| node.get(key).and_then(|value| reader.integer(value, &format!("{path}.{key}"), low, Some(90)));
625 let list = |reader: &mut Reader, key: &str| -> Vec<String> {
626 let Some(value) = node.get(key) else { return Vec::new() };
627 let found = reader.strings(value, &format!("{path}.{key}"), 0).unwrap_or_default();
628 if found.len() > 150 {
629 reader.problem(value, &format!("{path}.{key}"), "This list holds at most 150 names.");
630 }
631 found
632 };
633 Some(Cooldown {
634 default_days: days(self, "default-days", 1),
635 major_days: days(self, "semver-major-days", 1),
636 minor_days: days(self, "semver-minor-days", 1),
637 patch_days: days(self, "semver-patch-days", 0),
638 include: list(self, "include"),
639 exclude: list(self, "exclude"),
640 })
641 }
642
643 fn allow(&mut self, node: &Node, path: &str) -> Vec<UpdateAllow> {
644 let Value::Seq(items) = &node.value else {
645 self.problem(node, path, format!("This must be a list of rules, not {}.", node.kind()));
646 return Vec::new();
647 };
648 let mut rules = Vec::new();
649 for (index, item) in items.iter().enumerate() {
650 let at = format!("{path}[{index}]");
651 if self.map(item, &at).is_none() {
652 continue;
653 }
654 self.keys(item, &at, &["dependency-name", "dependency-type", "update-types"], "an `allow` rule");
655 let dependency = item.get("dependency-name").and_then(|value| self.nonempty(value, &format!("{at}.dependency-name")));
656 let dependency_type =
657 item.get("dependency-type").and_then(|value| self.one_of(value, &format!("{at}.dependency-type"), &DEPENDENCY_TYPES, "a dependency type"));
658 let update_types =
659 item.get("update-types").map(|value| self.update_types(value, &format!("{at}.update-types"), &SEMVER_UPDATE_TYPES)).unwrap_or_default();
660 if item.get("dependency-name").is_none() && item.get("dependency-type").is_none() {
661 self.problem(item, &at, "An `allow` rule needs dependency-name or dependency-type.");
662 }
663 rules.push(UpdateAllow { dependency, dependency_type, update_types });
664 }
665 rules
666 }
667
668 fn ignore(&mut self, node: &Node, path: &str) -> Vec<UpdateIgnore> {
669 let Value::Seq(items) = &node.value else {
670 self.problem(node, path, format!("This must be a list of rules, not {}.", node.kind()));
671 return Vec::new();
672 };
673 let mut rules = Vec::new();
674 for (index, item) in items.iter().enumerate() {
675 let at = format!("{path}[{index}]");
676 if self.map(item, &at).is_none() {
677 continue;
678 }
679 self.keys(item, &at, &["dependency-name", "versions", "update-types"], "an `ignore` rule");
680 let dependency = item
681 .get("dependency-name")
682 .and_then(|value| self.nonempty(value, &format!("{at}.dependency-name")))
683 .unwrap_or_else(|| "*".to_owned());
684 let versions = match item.get("versions") {
685 None => Vec::new(),
686 Some(value) if matches!(value.value, Value::Seq(_)) => self.strings(value, &format!("{at}.versions"), 1).unwrap_or_default(),
687 Some(value) => self.nonempty(value, &format!("{at}.versions")).into_iter().collect(),
688 };
689 let update_types =
690 item.get("update-types").map(|value| self.update_types(value, &format!("{at}.update-types"), &SEMVER_UPDATE_TYPES)).unwrap_or_default();
691 if item.as_map().is_some_and(<[_]>::is_empty) {
692 self.problem(item, &at, "An `ignore` rule needs dependency-name, versions or update-types.");
693 }
694 rules.push(UpdateIgnore { dependency, versions, update_types });
695 }
696 rules
697 }
698
699 fn registry(&mut self, name: &str, node: &Node, path: &str) -> Option<Registry> {
700 self.map(node, path)?;
701 let allowed: Vec<&str> = REGISTRY_KEYS.iter().chain(REGISTRY_MORE_KEYS.iter()).copied().collect();
702 self.keys(node, path, &allowed, "a registry");
703 let Some(kind_node) = node.get("type") else {
704 self.problem(node, path, format!("`type` is required: {}.", quote(&REGISTRY_TYPES)));
705 return None;
706 };
707 let kind = self.one_of(kind_node, &format!("{path}.type"), &REGISTRY_TYPES, "a registry type")?;
708 let url = match node.get("url") {
709 Some(url) => {
710 let text = self.nonempty(url, &format!("{path}.url"))?;
711 if text.starts_with("http://") {
712 self.problem(url, &format!("{path}.url"), "A registry is reached over https.");
713 }
714 text
715 }
716 None if kind == "hex-organization" => String::new(),
717 None => {
718 self.problem(node, path, "`url` is required.");
719 return None;
720 }
721 };
722 let field = |reader: &mut Reader, key: &str| node.get(key).and_then(|value| reader.text(value, &format!("{path}.{key}")));
723 let scopes = match node.get("scope") {
724 None => Vec::new(),
725 Some(value) if matches!(value.value, Value::Seq(_)) => self.strings(value, &format!("{path}.scope"), 1).unwrap_or_default(),
726 Some(value) => self.nonempty(value, &format!("{path}.scope")).into_iter().collect(),
727 };
728 if let Some(scope) = scopes.iter().find(|scope| !scope.starts_with('@')) {
729 let at = node.get("scope").unwrap_or(node);
730 self.problem(at, &format!("{path}.scope"), format!("`{scope}` is not an npm scope; a scope starts with @."));
731 }
732 let replaces_base = node.get("replaces-base").and_then(|value| self.boolean(value, &format!("{path}.replaces-base"))).unwrap_or(false);
733 let oidc = ["tenant-id", "client-id", "jfrog-oidc-provider-name", "aws-region", "role-name"].iter().any(|key| node.get(key).is_some());
734 Some(Registry {
735 name: name.to_owned(),
736 kind,
737 url,
738 username: field(self, "username"),
739 password: field(self, "password"),
740 token: field(self, "token"),
741 key: field(self, "key").or_else(|| field(self, "auth-key")),
742 replaces_base,
743 scopes,
744 oidc,
745 })
746 }
747
748 fn multi_group(&mut self, name: &str, node: &Node, path: &str) -> Option<MultiGroup> {
749 self.map(node, path)?;
750 self.keys(node, path, &MULTI_GROUP_KEYS, "a multi-ecosystem group");
751 let schedule = match node.get("schedule") {
752 Some(value) => self.schedule(value, &format!("{path}.schedule")),
753 None => {
754 self.problem(node, path, "`schedule` is required.");
755 None
756 }
757 };
758 if let Some(value) = node.get("update-types") {
759 self.update_types(value, &format!("{path}.update-types"), &LEVELS);
760 }
761 if let Some(value) = node.get("dependency-type") {
762 self.one_of(value, &format!("{path}.dependency-type"), &["development", "production"], "a dependency type");
763 }
764 if let Some(value) = node.get("exclude-patterns") {
765 self.strings(value, &format!("{path}.exclude-patterns"), 1);
766 }
767 Some(MultiGroup {
768 name: name.to_owned(),
769 schedule,
770 labels: node.get("labels").and_then(|value| self.strings(value, &format!("{path}.labels"), 0)),
771 assignees: node.get("assignees").and_then(|value| self.strings(value, &format!("{path}.assignees"), 1)).unwrap_or_default(),
772 milestone: node.get("milestone").and_then(|value| self.integer(value, &format!("{path}.milestone"), 1, None)),
773 target_branch: node.get("target-branch").and_then(|value| self.nonempty(value, &format!("{path}.target-branch"))),
774 commit_message: node.get("commit-message").and_then(|value| self.commit_message(value, &format!("{path}.commit-message"))),
775 branch_name: node.get("pull-request-branch-name").and_then(|value| self.branch_name(value, &format!("{path}.pull-request-branch-name"))),
776 open_pull_requests_limit: node
777 .get("open-pull-requests-limit")
778 .and_then(|value| self.integer(value, &format!("{path}.open-pull-requests-limit"), 0, None)),
779 })
780 }
781
782 fn entry(&mut self, index: usize, node: &Node, config: &Config) -> Option<Entry> {
783 let path = format!("updates[{index}]");
784 self.map(node, &path)?;
785 self.keys(node, &path, &ENTRY_KEYS[..ENTRY_KEYS.len() - 1], "an `updates` entry");
786 let ecosystem = match node.get("package-ecosystem") {
787 None => {
788 self.problem(node, &path, "`package-ecosystem` is required, such as npm, cargo, gomod or pip.");
789 None
790 }
791 Some(value) => {
792 let text = self.nonempty(value, &format!("{path}.package-ecosystem"));
793 match text {
794 Some(text) if ECOSYSTEMS.contains(&text.as_str()) || config.beta => Some(text),
795 Some(text) => {
796 self.problem(value, &format!("{path}.package-ecosystem"), format!("`{text}` is not a package ecosystem. Use one of {}.", ECOSYSTEMS.join(", ")));
797 None
798 }
799 None => None,
800 }
801 }
802 };
803 let directories = match (node.get("directory"), node.get("directories")) {
804 (Some(_), Some(both)) => {
805 self.problem(both, &path, "Give `directory` or `directories`, not both.");
806 Vec::new()
807 }
808 (Some(one), None) => self.directory(one, &format!("{path}.directory"), false).into_iter().collect(),
809 (None, Some(many)) => {
810 let listed = self.strings(many, &format!("{path}.directories"), 1).unwrap_or_default();
811 let Value::Seq(items) = &many.value else { return None };
812 let mut out = Vec::new();
813 for ((offset, item), _) in items.iter().enumerate().zip(&listed) {
814 if let Some(directory) = self.directory(item, &format!("{path}.directories[{offset}]"), true)
815 && !out.contains(&directory)
816 {
817 out.push(directory);
818 }
819 }
820 out
821 }
822 (None, None) => {
823 self.problem(node, &path, "`directory` (or `directories`) is required: where the manifest is, such as \"/\".");
824 Vec::new()
825 }
826 };
827 let multi_ecosystem_group = node.get("multi-ecosystem-group").and_then(|value| {
828 let name = self.nonempty(value, &format!("{path}.multi-ecosystem-group"))?;
829 if !config.multi_groups.iter().any(|group| group.name == name) {
830 self.problem(value, &format!("{path}.multi-ecosystem-group"), format!("`{name}` is not one of the groups under multi-ecosystem-groups."));
831 }
832 Some(name)
833 });
834 let schedule = match node.get("schedule") {
835 Some(value) => self.schedule(value, &format!("{path}.schedule")),
836 None if multi_ecosystem_group.is_some() => None,
837 None => {
838 self.problem(node, &path, "`schedule` is required, such as schedule: { interval: weekly }.");
839 None
840 }
841 };
842 let schedule = schedule.or_else(|| {
843 let group = multi_ecosystem_group.as_deref()?;
844 config.multi_groups.iter().find(|found| found.name == group)?.schedule.clone()
845 });
846 let patterns = node.get("patterns").and_then(|value| self.strings(value, &format!("{path}.patterns"), 1)).unwrap_or_default();
847 if multi_ecosystem_group.is_some() && node.get("patterns").is_none() {
848 self.problem(node, &path, "An entry in a multi-ecosystem group needs `patterns`; use [\"*\"] for every dependency.");
849 }
850 let registries = match node.get("registries") {
851 None => Vec::new(),
852 Some(value) if value.scalar().as_deref() == Some("*") => config.registries.iter().map(|registry| registry.name.clone()).collect(),
853 Some(value) => {
854 let names = self.strings(value, &format!("{path}.registries"), 1).unwrap_or_default();
855 for name in &names {
856 if !config.registries.iter().any(|registry| &registry.name == name) {
857 self.problem(value, &format!("{path}.registries"), format!("`{name}` is not one of the registries under the top-level registries."));
858 }
859 }
860 if names.len() > 100 {
861 self.problem(value, &format!("{path}.registries"), "An entry uses at most 100 registries.");
862 }
863 names
864 }
865 };
866 let name = node.get("name").and_then(|value| {
867 let text = self.text(value, &format!("{path}.name"))?;
868 if !(3..=100).contains(&text.chars().count()) {
869 self.problem(value, &format!("{path}.name"), "A name is from 3 to 100 characters.");
870 }
871 Some(text)
872 });
873 let versioning_strategy = node
874 .get("versioning-strategy")
875 .and_then(|value| self.one_of(value, &format!("{path}.versioning-strategy"), &STRATEGIES, "a versioning strategy"));
876 Some(Entry {
877 line: node.line,
878 ecosystem: ecosystem?,
879 directories,
880 schedule,
881 allow: node.get("allow").map(|value| self.allow(value, &format!("{path}.allow"))).unwrap_or_default(),
882 ignore: node.get("ignore").map(|value| self.ignore(value, &format!("{path}.ignore"))).unwrap_or_default(),
883 groups: node.get("groups").map(|value| self.groups(value, &format!("{path}.groups"))).unwrap_or_default(),
884 cooldown: node.get("cooldown").and_then(|value| self.cooldown(value, &format!("{path}.cooldown"))),
885 assignees: node.get("assignees").and_then(|value| self.strings(value, &format!("{path}.assignees"), 1)).unwrap_or_default(),
886 reviewers: node.get("reviewers").and_then(|value| self.strings(value, &format!("{path}.reviewers"), 1)).unwrap_or_default(),
887 labels: node.get("labels").and_then(|value| self.strings(value, &format!("{path}.labels"), 0)),
888 milestone: node.get("milestone").and_then(|value| self.integer(value, &format!("{path}.milestone"), 1, None)),
889 commit_message: node.get("commit-message").and_then(|value| self.commit_message(value, &format!("{path}.commit-message"))),
890 open_pull_requests_limit: node
891 .get("open-pull-requests-limit")
892 .and_then(|value| self.integer(value, &format!("{path}.open-pull-requests-limit"), 0, None))
893 .unwrap_or(5),
894 branch_name: node
895 .get("pull-request-branch-name")
896 .and_then(|value| self.branch_name(value, &format!("{path}.pull-request-branch-name")))
897 .unwrap_or_default(),
898 rebase_strategy: node
899 .get("rebase-strategy")
900 .and_then(|value| self.one_of(value, &format!("{path}.rebase-strategy"), &["auto", "disabled"], "a rebase strategy"))
901 .unwrap_or_else(|| "auto".to_owned()),
902 target_branch: node.get("target-branch").and_then(|value| {
903 let text = self.nonempty(value, &format!("{path}.target-branch"))?;
904 if !valid_ref_part(&text) {
905 self.problem(value, &format!("{path}.target-branch"), format!("`{text}` is not a branch name."));
906 return None;
907 }
908 Some(text)
909 }),
910 vendor: node.get("vendor").and_then(|value| self.boolean(value, &format!("{path}.vendor"))).unwrap_or(false),
911 versioning_strategy,
912 insecure_external_code_execution: node.get("insecure-external-code-execution").and_then(|value| {
913 self.one_of(value, &format!("{path}.insecure-external-code-execution"), &["allow", "deny"], "something this option takes")
914 }),
915 exclude_paths: node.get("exclude-paths").and_then(|value| self.strings(value, &format!("{path}.exclude-paths"), 0)).unwrap_or_default(),
916 registries,
917 patterns,
918 multi_ecosystem_group,
919 name,
920 options: node.to_json(),
921 })
922 }
923}
924
925/// Whether `text` can be part of a branch name.
926pub fn valid_ref_part(text: &str) -> bool {
927 !text.is_empty()
928 && !text.starts_with(['-', '/', '.'])
929 && !text.ends_with(['/', '.'])
930 && !text.ends_with(".lock")
931 && !text.contains("..")
932 && !text.contains("@{")
933 && !text.contains("//")
934 && !text.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\".contains(c))
935}
936
937fn valid_group_name(name: &str) -> bool {
938 let edge = |c: Option<char>| c.is_some_and(|c| c.is_ascii_alphanumeric());
939 edge(name.chars().next()) && edge(name.chars().last()) && name.chars().all(|c| c.is_ascii_alphanumeric() || "|_-".contains(c))
940}
941
942/// What is wrong with a `pull-request-branch-name.template`, if anything.
943fn template_problem(template: &str) -> Option<String> {
944 if template.chars().count() > 200 {
945 return Some("A template is at most 200 characters.".to_owned());
946 }
947 let mut rest = template;
948 while let Some(open) = rest.find(['{', '}']) {
949 if rest[open..].starts_with('}') {
950 return Some("The template has a `}` with no `{` before it.".to_owned());
951 }
952 let after = &rest[open + 1..];
953 let Some(close) = after.find('}') else {
954 return Some("The template has a `{` that is never closed.".to_owned());
955 };
956 let name = &after[..close];
957 if !TEMPLATE_PLACEHOLDERS.contains(&name) {
958 return Some(format!("`{{{name}}}` is not a placeholder. Use {}.", TEMPLATE_PLACEHOLDERS.map(|p| format!("{{{p}}}")).join(", ")));
959 }
960 rest = &after[close + 1..];
961 }
962 None
963}
964
965/// Reads the dependency update file.
966pub fn read(source: &str) -> Read {
967 let mut reader = Reader { problems: Vec::new() };
968 let root = match yaml::parse(source) {
969 Ok(root) => root,
970 Err(error) => {
971 return Read {
972 config: Config::default(),
973 problems: vec![ConfigProblem { line: error.line, column: error.column, key: String::new(), message: error.message }],
974 };
975 }
976 };
977 let mut config = Config::default();
978 if root.as_map().is_none() {
979 reader.problem(&root, "", "The file must be a mapping with `version: 2` and `updates`.");
980 return Read { config, problems: reader.problems };
981 }
982 reader.keys(&root, "", &ROOT_KEYS, "the file");
983 match root.get("version") {
984 None => reader.problem(&root, "version", "`version: 2` is required."),
985 Some(version) => {
986 let two = matches!(&version.value, Value::Int(2)) || matches!(&version.value, Value::Text { text, .. } if text.trim() == "2");
987 if !two {
988 reader.problem(version, "version", format!("The version must be 2, not {}.", version.scalar().unwrap_or_else(|| version.kind().to_owned())));
989 }
990 }
991 }
992 if let Some(beta) = root.get("enable-beta-ecosystems") {
993 config.beta = reader.boolean(beta, "enable-beta-ecosystems").unwrap_or(false);
994 }
995 if let Some(registries) = root.get("registries")
996 && let Some(entries) = reader.map(registries, "registries")
997 {
998 if entries.len() > MAX_REGISTRIES {
999 reader.problem(registries, "registries", format!("The file defines at most {MAX_REGISTRIES} registries."));
1000 }
1001 for (key, value) in entries {
1002 let name = key.scalar().unwrap_or_default();
1003 if let Some(registry) = reader.registry(&name, value, &format!("registries.{name}")) {
1004 config.registries.push(registry);
1005 }
1006 }
1007 }
1008 if let Some(groups) = root.get("multi-ecosystem-groups")
1009 && let Some(entries) = reader.map(groups, "multi-ecosystem-groups")
1010 {
1011 if entries.is_empty() {
1012 reader.problem(groups, "multi-ecosystem-groups", "This must name at least one group.");
1013 }
1014 for (key, value) in entries {
1015 let name = key.scalar().unwrap_or_default();
1016 if let Some(group) = reader.multi_group(&name, value, &format!("multi-ecosystem-groups.{name}")) {
1017 config.multi_groups.push(group);
1018 }
1019 }
1020 }
1021 match root.get("updates") {
1022 None => reader.problem(&root, "updates", "`updates` is required: a list of entries, one per package ecosystem and directory."),
1023 Some(updates) => match &updates.value {
1024 Value::Seq(items) => {
1025 if items.len() > MAX_ENTRIES {
1026 reader.problem(updates, "updates", format!("The file has {} entries; it may have at most {MAX_ENTRIES}.", items.len()));
1027 }
1028 for (index, item) in items.iter().enumerate() {
1029 let Some(entry) = reader.entry(index, item, &config) else { continue };
1030 let overlap = config.updates.iter().find(|other| {
1031 other.ecosystem == entry.ecosystem
1032 && other.target_branch == entry.target_branch
1033 && other.directories.iter().any(|directory| entry.directories.contains(directory))
1034 });
1035 if let Some(other) = overlap {
1036 reader.problem(
1037 item,
1038 &format!("updates[{index}]"),
1039 format!(
1040 "{} in {} is already covered by the entry on line {}; each ecosystem, directory and target branch is listed once.",
1041 entry.ecosystem,
1042 entry.directories.join(", "),
1043 other.line
1044 ),
1045 );
1046 }
1047 config.updates.push(entry);
1048 }
1049 }
1050 _ => reader.problem(updates, "updates", format!("`updates` must be a list of entries, not {}.", updates.kind())),
1051 },
1052 }
1053 reader.problems.sort_by_key(|problem| (problem.line, problem.column));
1054 Read { config, problems: reader.problems }
1055}
1056
1057/// The label an update for `ecosystem` carries beside `dependencies` when
1058/// its entry names none: the language or tool it is for.
1059pub fn ecosystem_label(ecosystem: &str) -> Option<&'static str> {
1060 Some(match ecosystem {
1061 "npm" | "bun" => "javascript",
1062 "cargo" => "rust",
1063 "pip" | "uv" | "poetry" | "pipenv" | "pip-compile" => "python",
1064 "gomod" => "go",
1065 "bundler" => "ruby",
1066 "maven" | "gradle" => "java",
1067 "composer" => "php",
1068 "nuget" | "dotnet-sdk" => ".NET",
1069 "docker" | "docker-compose" => "docker",
1070 "github-actions" => "github_actions",
1071 "mix" => "elixir",
1072 "pub" => "dart",
1073 "swift" => "swift",
1074 "terraform" => "terraform",
1075 "elm" => "elm",
1076 "gitsubmodule" => "submodules",
1077 "helm" => "helm",
1078 "devcontainers" => "devcontainers",
1079 _ => return None,
1080 })
1081}
1082
1083/// The labels an update pull request carries: the entry's `labels`, none
1084/// for an empty list, and without the option `dependencies` and the
1085/// ecosystem's label. Labels the repository lacks are created.
1086pub fn update_labels(labels: Option<&[String]>, ecosystem: &str) -> Vec<String> {
1087 match labels {
1088 Some(labels) => labels.to_vec(),
1089 None => std::iter::once("dependencies")
1090 .chain(ecosystem_label(ecosystem))
1091 .map(|label| label.to_lowercase())
1092 .collect(),
1093 }
1094}
1095
1096/// Which options of `entry` g1t reads but does not act on, each with why,
1097/// for the Security page and the docs' promise that nothing is silently
1098/// ignored.
1099/// `labels`, `milestone` and `target-branch` are acted on: see
1100/// [`update_labels`] and the pull request's base.
1101pub fn notes(entry: &Entry, _default_branch: Option<&str>) -> Vec<String> {
1102 let mut notes = Vec::new();
1103 if !entry.supported() {
1104 notes.push(format!(
1105 "g1t does not open version update pull requests for {} yet; it opens them for {}. The entry is read and checked.",
1106 entry.ecosystem,
1107 SUPPORTED.join(", ")
1108 ));
1109 }
1110 if entry.vendor {
1111 notes.push("vendor: vendored copies of dependencies are not updated.".to_owned());
1112 }
1113 if entry.multi_ecosystem_group.is_some() {
1114 notes.push(
1115 "multi-ecosystem-group: the group's schedule is used, and its updates open one pull request per ecosystem rather than one for the group."
1116 .to_owned(),
1117 );
1118 }
1119 if entry.insecure_external_code_execution.as_deref() == Some("allow") {
1120 notes.push("insecure-external-code-execution: g1t never runs a manifest's code while updating it.".to_owned());
1121 }
1122 if !entry.patterns.is_empty() && entry.multi_ecosystem_group.is_none() {
1123 notes.push("patterns: only read for an entry in a multi-ecosystem group.".to_owned());
1124 }
1125 notes
1126}
1127
1128/// Package names matched as the file's patterns are: `*` stands for any
1129/// run of characters, and case is ignored.
1130pub fn matches(pattern: &str, name: &str) -> bool {
1131 let pattern = pattern.to_lowercase();
1132 let name = name.to_lowercase();
1133 let parts: Vec<&str> = pattern.split('*').collect();
1134 if parts.len() == 1 {
1135 return pattern == name;
1136 }
1137 let mut rest = name.as_str();
1138 for (index, part) in parts.iter().enumerate() {
1139 if index == 0 {
1140 let Some(after) = rest.strip_prefix(part) else { return false };
1141 rest = after;
1142 } else if index == parts.len() - 1 {
1143 return rest.ends_with(part);
1144 } else {
1145 let Some(at) = rest.find(part) else { return false };
1146 rest = &rest[at + part.len()..];
1147 }
1148 }
1149 true
1150}
1151
1152/// Paths matched as `directories` and `exclude-paths` globs: `*` within one
1153/// segment, `**` across any number, `?` for one character.
1154pub fn glob(pattern: &str, path: &str) -> bool {
1155 fn segments(pattern: &[&str], path: &[&str]) -> bool {
1156 match (pattern.first(), path.first()) {
1157 (None, None) => true,
1158 (Some(&"**"), _) => segments(&pattern[1..], path) || (!path.is_empty() && segments(pattern, &path[1..])),
1159 (Some(part), Some(name)) => segment(part, name) && segments(&pattern[1..], &path[1..]),
1160 _ => false,
1161 }
1162 }
1163 fn segment(pattern: &str, name: &str) -> bool {
1164 let (p, n): (Vec<char>, Vec<char>) = (pattern.chars().collect(), name.chars().collect());
1165 fn go(p: &[char], n: &[char]) -> bool {
1166 match (p.first(), n.first()) {
1167 (None, None) => true,
1168 (Some('*'), _) => go(&p[1..], n) || (!n.is_empty() && go(p, &n[1..])),
1169 (Some('?'), Some(_)) => go(&p[1..], &n[1..]),
1170 (Some(a), Some(b)) => a == b && go(&p[1..], &n[1..]),
1171 _ => false,
1172 }
1173 }
1174 go(&p, &n)
1175 }
1176 let split = |text: &str| -> Vec<String> { text.trim_matches('/').split('/').filter(|part| !part.is_empty()).map(str::to_owned).collect() };
1177 let (pattern, path) = (split(pattern), split(path));
1178 let pattern: Vec<&str> = pattern.iter().map(String::as_str).collect();
1179 let path: Vec<&str> = path.iter().map(String::as_str).collect();
1180 segments(&pattern, &path)
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186
1187 fn problems(source: &str) -> Vec<String> {
1188 read(source).problems.iter().map(ConfigProblem::sentence).collect()
1189 }
1190
1191 fn only(source: &str) -> Entry {
1192 let found = read(source);
1193 assert!(found.problems.is_empty(), "{:?}", found.problems);
1194 found.config.updates.into_iter().next().unwrap()
1195 }
1196
1197 #[test]
1198 fn every_option_is_read() {
1199 let source = r#"
1200version: 2
1201enable-beta-ecosystems: false
1202registries:
1203 npm-acme:
1204 type: npm-registry
1205 url: https://npm.acme.dev
1206 token: ${{secrets.ACME_NPM_TOKEN}}
1207 replaces-base: true
1208 scope: "@acme"
1209 pypi:
1210 type: python-index
1211 url: https://pypi.acme.dev/simple
1212 username: ci
1213 password: ${{ secrets.PYPI_PASSWORD }}
1214multi-ecosystem-groups:
1215 infrastructure:
1216 schedule:
1217 interval: weekly
1218 assignees: ["ana"]
1219updates:
1220 - package-ecosystem: npm
1221 directories: ["/", "/packages/*"]
1222 schedule:
1223 interval: weekly
1224 day: tuesday
1225 time: "09:30"
1226 timezone: Europe/Berlin
1227 allow:
1228 - dependency-type: production
1229 - dependency-name: "@acme/*"
1230 update-types: ["version-update:semver-minor"]
1231 ignore:
1232 - dependency-name: react
1233 versions: [">=19"]
1234 - dependency-name: "*"
1235 update-types: ["version-update:semver-major"]
1236 - dependency-name: left-pad
1237 versions: "1.x"
1238 groups:
1239 lint:
1240 patterns: ["eslint*", "@typescript-eslint/*"]
1241 exclude-patterns: ["eslint-plugin-legacy"]
1242 update-types: [minor, patch]
1243 dependency-type: development
1244 fixes:
1245 applies-to: security-updates
1246 patterns: ["*"]
1247 everything:
1248 group-by: dependency-name
1249 cooldown:
1250 default-days: 5
1251 semver-major-days: 30
1252 semver-minor-days: 7
1253 semver-patch-days: 0
1254 include: ["*"]
1255 exclude: ["@acme/*"]
1256 assignees: [ana]
1257 reviewers: [ben]
1258 labels: [deps, javascript]
1259 milestone: 4
1260 commit-message:
1261 prefix: "build"
1262 prefix-development: chore
1263 include: scope
1264 open-pull-requests-limit: 10
1265 pull-request-branch-name:
1266 separator: "-"
1267 prefix: deps
1268 max-length: 80
1269 word-separator: "-"
1270 branch-name-case: lowercase
1271 template: "{prefix}/{package_manager}/{name}"
1272 rebase-strategy: disabled
1273 target-branch: develop
1274 versioning-strategy: increase-if-necessary
1275 insecure-external-code-execution: deny
1276 exclude-paths: ["vendor/**", "src/test/assets"]
1277 registries: ["npm-acme"]
1278 name: Web dependencies
1279 - package-ecosystem: pip
1280 directory: /
1281 registries: "*"
1282 vendor: false
1283 schedule:
1284 interval: cron
1285 cronjob: "every weekday at 6am"
1286 - package-ecosystem: docker
1287 directory: /
1288 patterns: ["nginx"]
1289 multi-ecosystem-group: infrastructure
1290"#;
1291 let found = read(source);
1292 assert!(found.problems.is_empty(), "{:?}", found.problems);
1293 let config = found.config;
1294 assert_eq!(config.registries.len(), 2);
1295 assert_eq!(config.registries[0].info().secrets, ["ACME_NPM_TOKEN"]);
1296 assert_eq!(config.registries[1].info().secrets, ["PYPI_PASSWORD"]);
1297 assert_eq!(config.registries[0].scopes, ["@acme"]);
1298 let npm = &config.updates[0];
1299 assert_eq!(npm.directories, ["/", "/packages/*"]);
1300 let schedule = npm.schedule.as_ref().unwrap();
1301 assert_eq!((schedule.interval, schedule.day, schedule.time), (Interval::Weekly, Some(2), Some((9, 30))));
1302 assert_eq!(schedule.timezone.as_deref(), Some("Europe/Berlin"));
1303 assert_eq!(npm.allow.len(), 2);
1304 assert_eq!(npm.ignore[1], UpdateIgnore { dependency: "*".into(), versions: vec![], update_types: vec!["version-update:semver-major".into()] });
1305 assert_eq!(npm.ignore[2].versions, ["1.x"]);
1306 assert_eq!(npm.groups.len(), 3);
1307 assert_eq!(npm.groups[0].update_types, ["minor", "patch"]);
1308 assert_eq!(npm.groups[0].dependency_type.as_deref(), Some("development"));
1309 assert_eq!(npm.groups[1].applies_to, "security-updates");
1310 assert_eq!(npm.groups[2].group_by.as_deref(), Some("dependency-name"));
1311 assert_eq!(npm.cooldown.as_ref().unwrap().patch_days, Some(0));
1312 assert_eq!(npm.commit_message, Some(CommitMessage { prefix: Some("build".into()), prefix_development: Some("chore".into()), scope: true }));
1313 assert_eq!(npm.open_pull_requests_limit, 10);
1314 assert_eq!(npm.branch_name.separator, "-");
1315 assert_eq!(npm.branch_name.template.as_deref(), Some("{prefix}/{package_manager}/{name}"));
1316 assert_eq!(npm.rebase_strategy, "disabled");
1317 assert_eq!(npm.target_branch.as_deref(), Some("develop"));
1318 assert_eq!(npm.versioning_strategy.as_deref(), Some("increase-if-necessary"));
1319 assert_eq!(npm.milestone, Some(4));
1320 assert_eq!(npm.labels.as_deref(), Some(&["deps".to_owned(), "javascript".to_owned()][..]));
1321 assert_eq!(npm.id(), "npm:/,/packages/*@develop");
1322 assert_eq!(npm.options["commit-message"]["prefix"], "build");
1323 let pip = &config.updates[1];
1324 assert_eq!(pip.registries, ["npm-acme", "pypi"]);
1325 assert_eq!(pip.schedule.as_ref().unwrap().cron.as_deref(), Some("0 6 * * 1-5"));
1326 let docker = &config.updates[2];
1327 assert!(!docker.supported());
1328 assert_eq!(docker.schedule.as_ref().unwrap().interval, Interval::Weekly);
1329 assert!(notes(docker, Some("main")).iter().any(|note| note.contains("does not open version update pull requests for docker")));
1330 // labels, milestone and target-branch are acted on, so nothing is said of them.
1331 assert!(!notes(npm, Some("main")).iter().any(|note| {
1332 note.starts_with("target-branch") || note.starts_with("labels") || note.starts_with("milestone")
1333 }));
1334 assert_eq!(update_labels(npm.labels.as_deref(), &npm.ecosystem), ["deps", "javascript"]);
1335 assert_eq!(update_labels(None, "cargo"), ["dependencies", "rust"]);
1336 assert_eq!(update_labels(None, "github-actions"), ["dependencies", "github_actions"]);
1337 assert_eq!(update_labels(None, "nuget"), ["dependencies", ".net"]);
1338 assert_eq!(update_labels(Some(&[]), "npm"), Vec::<String>::new());
1339 }
1340
1341 #[test]
1342 fn defaults() {
1343 let entry = only("version: 2\nupdates:\n - package-ecosystem: cargo\n directory: \"/\"\n schedule:\n interval: daily\n");
1344 assert_eq!(entry.directories, ["/"]);
1345 assert_eq!(entry.open_pull_requests_limit, 5);
1346 assert_eq!(entry.rebase_strategy, "auto");
1347 assert_eq!(entry.branch_name, BranchName::default());
1348 assert_eq!(entry.labels, None);
1349 assert!(entry.supported());
1350 // The version may be written as text.
1351 assert!(read("version: \"2\"\nupdates: []\n").problems.is_empty());
1352 // A directory is written with or without its slashes.
1353 assert_eq!(only("version: 2\nupdates:\n - package-ecosystem: npm\n directory: web/app/\n schedule: {interval: weekly}\n").directories, ["/web/app"]);
1354 }
1355
1356 #[test]
1357 fn problems_name_their_line_and_key() {
1358 for (source, said) in [
1359 ("updates: []", "version: `version: 2` is required."),
1360 ("version: 1\nupdates: []", "line 1, version: The version must be 2, not 1."),
1361 ("version: 2", "updates: `updates` is required"),
1362 ("version: 2\nupdates: []\nextra: 1", "line 3, `extra` is not an option of the file."),
1363 ("version: 2\nupdates: {}", "line 2, updates: `updates` must be a list of entries, not a mapping."),
1364 ("version: 2\nupdates:\n - directory: /\n schedule: {interval: daily}", "line 3, updates[0]: `package-ecosystem` is required"),
1365 ("version: 2\nupdates:\n - package-ecosystem: npmx\n directory: /\n schedule: {interval: daily}", "line 3, updates[0].package-ecosystem: `npmx` is not a package ecosystem"),
1366 ("version: 2\nupdates:\n - package-ecosystem: npm\n schedule: {interval: daily}", "`directory` (or `directories`) is required"),
1367 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n directories: [/]\n schedule: {interval: daily}", "not both"),
1368 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n", "`schedule` is required"),
1369 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: hourly\n", "line 6, updates[0].schedule.interval: `hourly` is not an interval. Use `daily`, `weekly`, `monthly`, `quarterly`, `semiannually`, `yearly` or `cron`."),
1370 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n day: someday\n", "`someday` is not a day of the week"),
1371 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n time: \"9am\"\n", "`9am` is not a time"),
1372 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n timezone: Mars/Base\n", "`Mars/Base` is not a time zone"),
1373 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: cron\n", "`cronjob` is required"),
1374 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: cron\n cronjob: \"whenever\"\n", "neither a cron expression"),
1375 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n open-pull-requests-limit: -1\n", "whole number of 0 or more"),
1376 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n labels: deps\n", "This must be a list, not text."),
1377 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n assignees: []\n", "This list must not be empty."),
1378 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n reviewers: [a, a]\n", "`a` is listed twice."),
1379 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n groups:\n lint:\n pattern: [x]\n", "`pattern` is not an option of a group."),
1380 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n groups:\n \"-bad\":\n patterns: [x]\n", "A group's name starts and ends"),
1381 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n groups:\n a:\n update-types: [huge]\n", "`huge` is not an update type"),
1382 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n ignore:\n - {}\n", "An `ignore` rule needs"),
1383 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n ignore:\n - dependency-name: x\n update-types: [major]\n", "`major` is not an update type. Use `version-update:semver-major`"),
1384 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n allow:\n - update-types: [\"version-update:semver-patch\"]\n", "An `allow` rule needs dependency-name or dependency-type."),
1385 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n allow:\n - dependency-type: dev\n", "`dev` is not a dependency type"),
1386 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n cooldown:\n default-days: 91\n", "from 1 to 90"),
1387 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n commit-message:\n prefix: \"012345678901234567890123456789012345678901234567890\"\n", "at most 50 characters"),
1388 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n commit-message:\n include: everything\n", "`everything` is not something `include` takes"),
1389 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n pull-request-branch-name:\n separator: \"+\"\n", "`+` is not a separator"),
1390 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n pull-request-branch-name:\n template: \"{prefix}/{nope}\"\n", "`{nope}` is not a placeholder"),
1391 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n rebase-strategy: squashed\n", "`squashed` is not a rebase strategy"),
1392 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n versioning-strategy: newest\n", "`newest` is not a versioning strategy"),
1393 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n registries: [nope]\n", "`nope` is not one of the registries"),
1394 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n vendor: yes\n", "This must be true or false"),
1395 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n milestone: 0\n", "whole number of 1 or more"),
1396 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n target-branch: \"a b\"\n", "is not a branch name"),
1397 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n multi-ecosystem-group: nope\n patterns: [\"*\"]\n", "`nope` is not one of the groups"),
1398 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: ../x\n schedule: {interval: daily}\n", "must stay inside the repository"),
1399 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /packages/*\n schedule: {interval: daily}\n", "use `directories` for globs"),
1400 ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n - package-ecosystem: npm\n directories: [/, /web]\n schedule: {interval: daily}\n", "already covered by the entry on line 3"),
1401 ("version: 2\nregistries:\n r:\n type: npm\n url: https://x\nupdates: []\n", "`npm` is not a registry type"),
1402 ("version: 2\nregistries:\n r:\n type: npm-registry\nupdates: []\n", "`url` is required."),
1403 ("version: 2\nregistries:\n r:\n type: npm-registry\n url: https://x\n scope: acme\nupdates: []\n", "`acme` is not an npm scope"),
1404 ("version: 2\nregistries:\n r:\n type: npm-registry\n url: https://x\n secret: y\nupdates: []\n", "`secret` is not an option of a registry."),
1405 ("version: 2\nmulti-ecosystem-groups:\n infra: {}\nupdates: []\n", "`schedule` is required."),
1406 ("version: 2\nupdates: [\n", "This is not valid YAML"),
1407 ("[1, 2]", "The file must be a mapping"),
1408 ] {
1409 let found = problems(source);
1410 assert!(found.iter().any(|problem| problem.contains(said)), "{source:?}\nexpected {said:?}\ngot {found:?}");
1411 }
1412 }
1413
1414 /// Real projects' files, as published, and one with every option: each
1415 /// is read without a problem.
1416 #[test]
1417 fn real_world_files_are_read() {
1418 let folder = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/dependabot");
1419 let mut read_files = 0;
1420 for file in std::fs::read_dir(&folder).unwrap() {
1421 let path = file.unwrap().path();
1422 let text = std::fs::read_to_string(&path).unwrap();
1423 let found = read(&text);
1424 assert!(found.problems.is_empty(), "{}: {:?}", path.display(), found.problems);
1425 assert!(!found.config.updates.is_empty(), "{}", path.display());
1426 read_files += 1;
1427 }
1428 assert!(read_files >= 20, "{read_files} fixtures");
1429 let every = read(&std::fs::read_to_string(folder.join("every-option.yml")).unwrap()).config;
1430 assert_eq!(every.registries.len(), 7);
1431 assert!(every.registries.iter().find(|registry| registry.name == "artifactory").unwrap().oidc);
1432 assert_eq!(every.multi_groups[0].open_pull_requests_limit, Some(3));
1433 assert_eq!(every.updates.len(), 9);
1434 assert_eq!(every.updates[0].directories, ["/", "/apps/*", "/packages/**"]);
1435 assert_eq!(every.updates[1].open_pull_requests_limit, 0);
1436 assert_eq!(every.updates[3].registries.len(), 7);
1437 assert_eq!(every.updates[4].schedule.as_ref().unwrap().day, Some(3));
1438 assert_eq!(every.updates.iter().filter(|entry| entry.supported()).count(), 4);
1439 }
1440
1441 #[test]
1442 fn beta_ecosystems_are_accepted_when_enabled() {
1443 let source = "version: 2\nenable-beta-ecosystems: true\nupdates:\n - package-ecosystem: fortran\n directory: /\n schedule: {interval: daily}\n";
1444 let entry = only(source);
1445 assert!(!entry.supported());
1446 }
1447
1448 #[test]
1449 fn every_problem_is_reported_not_just_the_first() {
1450 let source = "version: 3\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: hourly}\n labelz: []\n";
1451 let found = read(source).problems;
1452 assert_eq!(found.len(), 3, "{found:?}");
1453 assert_eq!(found.iter().map(|problem| problem.line).collect::<Vec<_>>(), [1, 5, 6]);
1454 }
1455
1456 #[test]
1457 fn patterns_and_globs() {
1458 assert!(matches("eslint*", "eslint-plugin-react"));
1459 assert!(matches("@types/*", "@types/node"));
1460 assert!(matches("*", "anything"));
1461 assert!(matches("*react*", "preact-render"));
1462 assert!(matches("React", "react"));
1463 assert!(!matches("eslint*", "typescript-eslint"));
1464 assert!(glob("/packages/*", "/packages/web"));
1465 assert!(!glob("/packages/*", "/packages/web/sub"));
1466 assert!(glob("/apps/**", "/apps/web/sub"));
1467 assert!(glob("**/*.md", "docs/guide.md"));
1468 assert!(glob("vendor/**", "vendor/a/b/package.json"));
1469 assert!(glob("src/*.js", "src/app.js"));
1470 assert!(!glob("src/*", "src/utils/helper.rb"));
1471 }
1472
1473 #[test]
1474 fn branch_name_parts() {
1475 assert!(valid_ref_part("deps"));
1476 assert!(valid_ref_part("release/1.x"));
1477 for bad in ["", "a b", "-x", "a..b", "x.lock", "a:b", "a~1", "x/"] {
1478 assert!(!valid_ref_part(bad), "{bad}");
1479 }
1480 assert!(template_problem("{prefix}/{package_manager}/{dependency}-{version}").is_none());
1481 assert!(template_problem("{prefix").unwrap().contains("never closed"));
1482 }
1483}