| 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 | } |