g1t/services/security/src/updates.rs

236 lines10,436 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.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! 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}