g1t/services/security/src/updates.rs

236 lines10,436 bytesCodeBlame
1//! Version updates: reading `.g1t/dependencies.yml`, which says which
2//! dependencies to keep current, how often, grouped how, and which to
3//! leave alone. The file is read and checked on every dependency scan and
4//! what it says is shown on the Security page; opening pull requests for
5//! new versions is not built yet, and the page says so.
6
7use g1t_contracts::security::{UpdateGroup, UpdateIgnore, VersionUpdateEntry};
8use serde_yaml::{Mapping, Value};
9
10/// Where the file lives on the default branch.
11pub const PATH: &str = ".g1t/dependencies.yml";
12/// What each entry's `ecosystem` may be.
13pub const ECOSYSTEMS: [&str; 4] = ["npm", "cargo", "go", "pip"];
14pub const INTERVALS: [&str; 3] = ["daily", "weekly", "monthly"];
15const DEFAULT_LIMIT: u32 = 5;
16const MAX_LIMIT: u64 = 20;
17const MAX_ENTRIES: usize = 50;
18
19const ENTRY_KEYS: [&str; 6] = ["ecosystem", "directory", "schedule", "open-pull-requests-limit", "groups", "ignore"];
20
21fn key<'a>(map: &'a Mapping, name: &str) -> Option<&'a Value> {
22 map.get(Value::String(name.to_owned()))
23}
24
25fn text(value: &Value, what: &str) -> Result<String, String> {
26 match value {
27 Value::String(text) => Ok(text.trim().to_owned()),
28 Value::Number(number) => Ok(number.to_string()),
29 _ => Err(format!("{what} must be text.")),
30 }
31}
32
33fn texts(value: &Value, what: &str) -> Result<Vec<String>, String> {
34 match value {
35 Value::Sequence(items) => items.iter().map(|item| text(item, what)).collect(),
36 Value::String(_) => Ok(vec![text(value, what)?]),
37 _ => Err(format!("{what} must be a list.")),
38 }
39}
40
41/// The directory as the file means it: from the repository's root, with a
42/// leading `/` and no trailing one.
43fn directory(raw: &str) -> Result<String, String> {
44 let trimmed = raw.trim().trim_end_matches('/');
45 if trimmed.split('/').any(|part| part == "..") {
46 return Err(format!("directory {raw} must stay inside the repository."));
47 }
48 Ok(if trimmed.is_empty() { "/".to_owned() } else if trimmed.starts_with('/') { trimmed.to_owned() } else { format!("/{trimmed}") })
49}
50
51fn entry(index: usize, value: &Value) -> Result<VersionUpdateEntry, String> {
52 let at = |message: String| format!("updates[{index}]: {message}");
53 let Value::Mapping(map) = value else {
54 return Err(at("each entry must be a mapping with an ecosystem.".to_owned()));
55 };
56 for name in map.keys() {
57 let name = name.as_str().unwrap_or_default();
58 if !ENTRY_KEYS.contains(&name) {
59 return Err(at(format!("unknown key {name}. Allowed: {}.", ENTRY_KEYS.join(", "))));
60 }
61 }
62 let ecosystem = text(key(map, "ecosystem").ok_or_else(|| at("ecosystem is required.".to_owned()))?, "ecosystem").map_err(at)?;
63 if !ECOSYSTEMS.contains(&ecosystem.as_str()) {
64 return Err(at(format!("ecosystem {ecosystem} is not one of {}.", ECOSYSTEMS.join(", "))));
65 }
66 let directory = match key(map, "directory") {
67 Some(value) => directory(&text(value, "directory").map_err(at)?).map_err(at)?,
68 None => "/".to_owned(),
69 };
70 let interval = match key(map, "schedule") {
71 None => "weekly".to_owned(),
72 Some(Value::Mapping(schedule)) => {
73 for name in schedule.keys() {
74 if name.as_str() != Some("interval") {
75 return Err(at(format!("schedule takes only interval, not {}.", name.as_str().unwrap_or("that"))));
76 }
77 }
78 match key(schedule, "interval") {
79 Some(value) => text(value, "schedule.interval").map_err(at)?,
80 None => "weekly".to_owned(),
81 }
82 }
83 Some(_) => return Err(at("schedule must be a mapping, such as schedule: { interval: weekly }.".to_owned())),
84 };
85 if !INTERVALS.contains(&interval.as_str()) {
86 return Err(at(format!("schedule.interval {interval} is not one of {}.", INTERVALS.join(", "))));
87 }
88 let open_pull_requests_limit = match key(map, "open-pull-requests-limit") {
89 None => DEFAULT_LIMIT,
90 Some(Value::Number(number)) => match number.as_u64() {
91 Some(limit) if limit <= MAX_LIMIT => limit as u32,
92 _ => return Err(at(format!("open-pull-requests-limit must be a whole number from 0 to {MAX_LIMIT}."))),
93 },
94 Some(_) => return Err(at(format!("open-pull-requests-limit must be a whole number from 0 to {MAX_LIMIT}."))),
95 };
96 let mut groups = Vec::new();
97 match key(map, "groups") {
98 None => {}
99 Some(Value::Mapping(found)) => {
100 for (name, group) in found {
101 let name = text(name, "a group's name").map_err(at)?;
102 let Value::Mapping(group) = group else {
103 return Err(at(format!("group {name} must be a mapping with patterns.")));
104 };
105 let patterns = texts(
106 key(group, "patterns").ok_or_else(|| at(format!("group {name} needs patterns.")))?,
107 "patterns",
108 )
109 .map_err(at)?;
110 if patterns.is_empty() || patterns.iter().any(String::is_empty) {
111 return Err(at(format!("group {name} needs at least one pattern.")));
112 }
113 groups.push(UpdateGroup { name, patterns });
114 }
115 }
116 Some(_) => return Err(at("groups must be a mapping of group names to patterns.".to_owned())),
117 }
118 let mut ignore = Vec::new();
119 match key(map, "ignore") {
120 None => {}
121 Some(Value::Sequence(items)) => {
122 for item in items {
123 let Value::Mapping(item) = item else {
124 return Err(at("each ignore entry must be a mapping with a dependency.".to_owned()));
125 };
126 let dependency = text(
127 key(item, "dependency").ok_or_else(|| at("each ignore entry needs a dependency.".to_owned()))?,
128 "dependency",
129 )
130 .map_err(at)?;
131 let versions = match key(item, "versions") {
132 Some(value) => texts(value, "versions").map_err(at)?,
133 None => Vec::new(),
134 };
135 ignore.push(UpdateIgnore { dependency, versions });
136 }
137 }
138 Some(_) => return Err(at("ignore must be a list.".to_owned())),
139 }
140 Ok(VersionUpdateEntry { ecosystem, directory, interval, groups, ignore, open_pull_requests_limit })
141}
142
143/// The entries of a `.g1t/dependencies.yml`, or what is wrong with it, in a
144/// sentence a person can act on.
145pub fn parse(source: &str) -> Result<Vec<VersionUpdateEntry>, String> {
146 let root: Value = serde_yaml::from_str(source).map_err(|error| format!("{PATH} is not valid YAML: {error}"))?;
147 let Value::Mapping(root) = root else {
148 return Err(format!("{PATH} must be a mapping with version and updates."));
149 };
150 for name in root.keys() {
151 let name = name.as_str().unwrap_or_default();
152 if name != "version" && name != "updates" {
153 return Err(format!("unknown key {name}. Allowed: version, updates."));
154 }
155 }
156 match key(&root, "version") {
157 None => {}
158 Some(Value::Number(number)) if number.as_u64() == Some(1) => {}
159 Some(_) => return Err("version must be 1.".to_owned()),
160 }
161 let Some(Value::Sequence(items)) = key(&root, "updates") else {
162 return Err("updates must be a list of entries.".to_owned());
163 };
164 if items.len() > MAX_ENTRIES {
165 return Err(format!("updates has {} entries; at most {MAX_ENTRIES}.", items.len()));
166 }
167 let mut entries: Vec<VersionUpdateEntry> = Vec::new();
168 for (index, item) in items.iter().enumerate() {
169 let found = entry(index, item)?;
170 if entries.iter().any(|other| other.ecosystem == found.ecosystem && other.directory == found.directory) {
171 return Err(format!(
172 "updates[{index}]: {} in {} is listed twice.",
173 found.ecosystem, found.directory
174 ));
175 }
176 entries.push(found);
177 }
178 Ok(entries)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn a_full_file_is_read() {
187 let source = r#"
188version: 1
189updates:
190 - ecosystem: npm
191 directory: web/
192 schedule:
193 interval: daily
194 open-pull-requests-limit: 3
195 groups:
196 lint:
197 patterns: ["eslint*", "@typescript-eslint/*"]
198 ignore:
199 - dependency: react
200 versions: [">=19"]
201 - dependency: left-pad
202 - ecosystem: cargo
203"#;
204 let entries = parse(source).unwrap();
205 assert_eq!(entries.len(), 2);
206 assert_eq!(entries[0].directory, "/web");
207 assert_eq!(entries[0].interval, "daily");
208 assert_eq!(entries[0].open_pull_requests_limit, 3);
209 assert_eq!(entries[0].groups, vec![UpdateGroup { name: "lint".into(), patterns: vec!["eslint*".into(), "@typescript-eslint/*".into()] }]);
210 assert_eq!(entries[0].ignore[0], UpdateIgnore { dependency: "react".into(), versions: vec![">=19".into()] });
211 assert!(entries[0].ignore[1].versions.is_empty());
212 // Defaults.
213 assert_eq!((entries[1].directory.as_str(), entries[1].interval.as_str(), entries[1].open_pull_requests_limit), ("/", "weekly", 5));
214 }
215
216 #[test]
217 fn mistakes_are_named() {
218 for (source, said) in [
219 ("updates: []\nextra: 1", "unknown key extra"),
220 ("version: 2\nupdates: []", "version must be 1"),
221 ("version: 1", "updates must be a list"),
222 ("updates:\n - ecosystem: maven", "ecosystem maven is not one of"),
223 ("updates:\n - directory: /", "ecosystem is required"),
224 ("updates:\n - ecosystem: npm\n schedule:\n interval: hourly", "interval hourly"),
225 ("updates:\n - ecosystem: npm\n open-pull-requests-limit: 99", "0 to 20"),
226 ("updates:\n - ecosystem: npm\n - ecosystem: npm\n directory: /", "listed twice"),
227 ("updates:\n - ecosystem: npm\n directory: ../x", "inside the repository"),
228 ("updates:\n - ecosystem: npm\n labels: [deps]", "unknown key labels"),
229 ("updates:\n - ecosystem: npm\n groups:\n a: {}", "needs patterns"),
230 ("updates: [", "not valid YAML"),
231 ] {
232 let error = parse(source).unwrap_err();
233 assert!(error.contains(said), "{source:?}: {error}");
234 }
235 }
236}