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 daily | 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 | ||
| 7 | use g1t_contracts::security::{UpdateGroup, UpdateIgnore, VersionUpdateEntry}; | |
| 8 | use serde_yaml::{Mapping, Value}; | |
| 9 | ||
| 10 | /// Where the file lives on the default branch. | |
| 11 | pub const PATH: &str = ".g1t/dependencies.yml"; | |
| 12 | /// What each entry's `ecosystem` may be. | |
| 13 | pub const ECOSYSTEMS: [&str; 4] = ["npm", "cargo", "go", "pip"]; | |
| 14 | pub const INTERVALS: [&str; 3] = ["daily", "weekly", "monthly"]; | |
| 15 | const DEFAULT_LIMIT: u32 = 5; | |
| 16 | const MAX_LIMIT: u64 = 20; | |
| 17 | const MAX_ENTRIES: usize = 50; | |
| 18 | ||
| 19 | const ENTRY_KEYS: [&str; 6] = ["ecosystem", "directory", "schedule", "open-pull-requests-limit", "groups", "ignore"]; | |
| 20 | ||
| 21 | fn key<'a>(map: &'a Mapping, name: &str) -> Option<&'a Value> { | |
| 22 | map.get(Value::String(name.to_owned())) | |
| 23 | } | |
| 24 | ||
| 25 | fn 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 | ||
| 33 | fn 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. | |
| 43 | fn 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 | ||
| 51 | fn 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. | |
| 145 | pub 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)] | |
| 182 | mod tests { | |
| 183 | use super::*; | |
| 184 | ||
| 185 | #[test] | |
| 186 | fn a_full_file_is_read() { | |
| 187 | let source = r#" | |
| 188 | version: 1 | |
| 189 | updates: | |
| 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 | } |