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.
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 1 | //! Secrets and variables, a repository's or its workspace's: one list for |
| 2 | //! every reader, shaped like Vercel's environment variables. Each row is a | |
| 3 | //! key, its type (a secret, or a variable shown as Config), the | |
| 4 | //! environments it applies to and who reads it: workflows, deployments, or | |
| 5 | //! both. A key may have one row per environment, so production and | |
| 6 | //! previews can hold different values; a key's rows never overlap. | |
| 7 | //! | |
| 8 | //! A reader asking for an environment gets the row naming it, else the | |
| 9 | //! key's row for every environment. A project's row overrides its | |
| 10 | //! workspace's of the same key. | |
| 11 | //! | |
| 12 | //! A repository's rows belong to its project (its primary one, when it | |
| 13 | //! carries several): asked for by repository, as GitHub's API does, they | |
| 14 | //! are the project's. Rows from before projects move over the first time | |
| 15 | //! they are touched. Names are upper-cased, as GitHub treats | |
| 16 | //! them without regard to case. Agents never read any. | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 17 | //! |
| 18 | //! A secret may be brokered (broker.rs): its row names the hosts it is for, | |
| 19 | //! and workflow jobs get a stand-in in place of its value. Only workflows | |
| 20 | //! read a brokered secret: a deployment's app needs the value itself. | |
| 21 | ||
| 22 | use std::collections::BTreeMap; | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 23 | |
| 24 | use g1t_contracts::access::Capability; | |
| 25 | use g1t_contracts::actions::{ | |
| 26 | CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs, | |
| 27 | SettingsOwner, | |
| 28 | }; | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 29 | use g1t_contracts::guardrails::normalize_domain; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 30 | use g1t_contracts::projects::{ByRepoArgs, ProjectRef}; |
| 31 | use g1t_contracts::time::rfc3339; | |
| 32 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; | |
| 33 | use g1t_kit::now_ms; | |
| 34 | use serde::Deserialize; | |
| 35 | use serde_json::{Map, Value}; | |
| 36 | use worker::Result; | |
| 37 | use worker::wasm_bindgen::JsValue; | |
| 38 | ||
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 39 | use crate::broker::{Broker, MAX_HOSTS, MIN_VALUE_CHARS}; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 40 | use crate::{Actions, check, fail}; |
| 41 | ||
| 42 | /// The largest value, as on GitHub. | |
| 43 | const MAX_VALUE_BYTES: usize = 48 * 1024; | |
| 44 | const MAX_PER_OWNER: u32 = 200; | |
| 45 | const MAX_NOTE: usize = 500; | |
| 46 | ||
| 47 | #[derive(Deserialize)] | |
| 48 | struct SettingRow { | |
| 49 | id: String, | |
| 50 | scope: String, | |
| 51 | kind: String, | |
| 52 | name: String, | |
| 53 | value: String, | |
| 54 | updated_at: String, | |
| 55 | available_to: String, | |
| 56 | environments: String, | |
| 57 | repositories: Option<String>, | |
| 58 | note: Option<String>, | |
| 59 | updated_by: Option<String>, | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 60 | /// A brokered secret's hosts, as a JSON array (migration 0019). |
| 61 | #[serde(default)] | |
| 62 | brokered_hosts: Option<String>, | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 63 | } |
| 64 | ||
| 65 | /// A name GitHub would accept: letters, digits and `_`, not starting with | |
| 66 | /// a digit, `GITHUB_` or `G1T_`, which are g1t's own (`G1T_TOKEN` and its | |
| 67 | /// alias `GITHUB_TOKEN`). | |
| 68 | fn valid_name(name: &str) -> Result<String, String> { | |
| 69 | let upper = name.trim().to_ascii_uppercase(); | |
| 70 | if upper.is_empty() || upper.len() > 100 { | |
| 71 | return Err("A name is 1 to 100 characters.".to_owned()); | |
| 72 | } | |
| 73 | if !upper.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { | |
| 74 | return Err("A name has only letters, digits and underscores.".to_owned()); | |
| 75 | } | |
| 76 | if upper.starts_with(|c: char| c.is_ascii_digit()) { | |
| 77 | return Err("A name cannot start with a digit.".to_owned()); | |
| 78 | } | |
| 79 | if upper.starts_with("GITHUB_") || upper.starts_with("G1T_") { | |
| 80 | return Err("Names starting with G1T_ or GITHUB_ are kept for g1t's own, such as G1T_TOKEN.".to_owned()); | |
| 81 | } | |
| 82 | Ok(upper) | |
| 83 | } | |
| 84 | ||
| 85 | /// Environments' names: lowercase letters, digits, `-` and `_`, each once. | |
| 86 | fn valid_environments(list: &[String]) -> Result<Vec<String>, String> { | |
| 87 | let mut out: Vec<String> = Vec::new(); | |
| 88 | for name in list { | |
| 89 | let lower = name.trim().to_ascii_lowercase(); | |
| 90 | if lower.is_empty() { | |
| 91 | continue; | |
| 92 | } | |
| 93 | if lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { | |
| 94 | return Err(format!("`{name}` is not an environment's name: up to 40 letters, digits, - and _.")); | |
| 95 | } | |
| 96 | if !out.contains(&lower) { | |
| 97 | out.push(lower); | |
| 98 | } | |
| 99 | } | |
| 100 | out.sort(); | |
| 101 | Ok(out) | |
| 102 | } | |
| 103 | ||
| 104 | fn consumers(list: &[String]) -> Result<Vec<String>, String> { | |
| 105 | let mut out: Vec<String> = Vec::new(); | |
| 106 | for item in list { | |
| 107 | let item = item.trim().to_ascii_lowercase(); | |
| 108 | if !CONSUMERS.contains(&item.as_str()) { | |
| 109 | return Err(format!("`{item}` is not a reader: use workflows or deployments.")); | |
| 110 | } | |
| 111 | if !out.contains(&item) { | |
| 112 | out.push(item); | |
| 113 | } | |
| 114 | } | |
| 115 | if out.is_empty() { | |
| 116 | return Err("Choose who reads it: workflows, deployments, or both.".to_owned()); | |
| 117 | } | |
| 118 | Ok(out) | |
| 119 | } | |
| 120 | ||
| Merge branch 'main' into ask-integration | 121 | /// A brokered secret's hosts, tidied as host names: each once, at |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 122 | /// most `MAX_HOSTS`. |
| 123 | fn valid_hosts(list: &[String]) -> Result<Vec<String>, String> { | |
| 124 | let mut out: Vec<String> = Vec::new(); | |
| 125 | for host in list { | |
| 126 | if host.trim().is_empty() { | |
| 127 | continue; | |
| 128 | } | |
| 129 | let host = normalize_domain(host)?; | |
| 130 | if !out.contains(&host) { | |
| 131 | out.push(host); | |
| 132 | } | |
| 133 | } | |
| 134 | if out.len() > MAX_HOSTS { | |
| 135 | return Err(format!("A brokered secret is for at most {MAX_HOSTS} hosts.")); | |
| 136 | } | |
| 137 | Ok(out) | |
| 138 | } | |
| 139 | ||
| 140 | /// What readers of one kind get: each key's value, and the hosts of the | |
| 141 | /// brokered secrets among them. | |
| 142 | #[derive(Default)] | |
| 143 | pub(crate) struct Resolved { | |
| 144 | pub values: Map<String, Value>, | |
| 145 | pub brokered: BTreeMap<String, Vec<String>>, | |
| 146 | } | |
| 147 | ||
| 148 | impl Resolved { | |
| 149 | /// The brokered secrets among the values. | |
| 150 | pub(crate) fn brokers(&self) -> Vec<Broker> { | |
| 151 | self.brokered | |
| 152 | .iter() | |
| 153 | .filter_map(|(name, hosts)| { | |
| 154 | let value = self.values.get(name)?.as_str()?; | |
| 155 | Some(Broker { name: name.clone(), value: value.to_owned(), hosts: hosts.clone() }) | |
| 156 | }) | |
| 157 | .collect() | |
| 158 | } | |
| 159 | ||
| 160 | /// The values, less the brokered secrets: for readers that would put | |
| 161 | /// them where no stand-in can work. | |
| 162 | fn unbrokered(mut self) -> Map<String, Value> { | |
| 163 | for name in self.brokered.keys() { | |
| 164 | self.values.remove(name); | |
| 165 | } | |
| 166 | self.values | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 170 | fn split(list: &str) -> Vec<String> { |
| 171 | list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect() | |
| 172 | } | |
| 173 | ||
| 174 | impl SettingRow { | |
| 175 | fn environments(&self) -> Vec<String> { | |
| 176 | split(&self.environments) | |
| 177 | } | |
| 178 | ||
| 179 | /// A workspace's row: the projects it reaches. The column predates | |
| 180 | /// projects; it holds their slugs. | |
| 181 | fn projects(&self) -> Vec<String> { | |
| 182 | self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default() | |
| 183 | } | |
| 184 | ||
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 185 | fn brokered_hosts(&self) -> Vec<String> { |
| 186 | self.brokered_hosts.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default() | |
| 187 | } | |
| 188 | ||
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 189 | fn reaches(&self, project: &str) -> bool { |
| 190 | let list = self.projects(); | |
| 191 | list.is_empty() || list.iter().any(|p| p.eq_ignore_ascii_case(project)) | |
| 192 | } | |
| 193 | ||
| 194 | /// Whether it and rows for `environments` would both apply somewhere. | |
| 195 | fn overlaps(&self, environments: &[String]) -> bool { | |
| 196 | let mine = self.environments(); | |
| 197 | mine.is_empty() == environments.is_empty() && (mine.is_empty() || mine.iter().any(|e| environments.contains(e))) | |
| 198 | } | |
| 199 | ||
| 200 | fn describe(self) -> Setting { | |
| 201 | Setting { | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 202 | brokered_hosts: if self.kind == "secret" { self.brokered_hosts() } else { Vec::new() }, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 203 | available_to: split(&self.available_to), |
| 204 | environments: self.environments(), | |
| 205 | projects: self.projects(), | |
| 206 | value: (self.kind == "variable").then_some(self.value), | |
| 207 | id: self.id, | |
| 208 | name: self.name, | |
| 209 | kind: self.kind, | |
| 210 | scope: self.scope, | |
| 211 | updated_at: self.updated_at, | |
| 212 | note: self.note, | |
| 213 | updated_by: self.updated_by, | |
| 214 | } | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | /// Where settings live: `(scope, owner)` with the owner a project id or a | |
| 219 | /// workspace slug. | |
| 220 | struct Place { | |
| 221 | scope: &'static str, | |
| 222 | owner: String, | |
| 223 | namespace: String, | |
| 224 | /// The project's slug, for a project's place. | |
| 225 | slug: String, | |
| 226 | } | |
| 227 | ||
| 228 | impl Actions { | |
| 229 | async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> { | |
| 230 | // A workspace's tokens, G1T_TOKEN among them, read the names but | |
| 231 | // never change them: a workflow must not rewrite what it runs with. | |
| 232 | if changing && actor.kind == PrincipalKind::Workspace { | |
| 233 | return Ok(fail( | |
| 234 | FailureCode::Forbidden, | |
| 235 | "A workspace's tokens, G1T_TOKEN included, cannot change secrets and variables. Use a person's token or the site.", | |
| 236 | )); | |
| 237 | } | |
| 238 | match (&owner.repo, &owner.workspace) { | |
| 239 | (Some(path), _) => { | |
| 240 | // A repository's secrets and variables, seen or changed, | |
| 241 | // go with its webhooks and deployments: the Admin role. | |
| 242 | let repo = match self.may(actor, path, Capability::ManageIntegrations).await? { | |
| 243 | Outcome::Ok(repo) => repo, | |
| 244 | Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)), | |
| 245 | }; | |
| 246 | let Some(project) = self.project_of(&repo.id).await? else { | |
| 247 | return Ok(fail(FailureCode::NotFound, "The repository has no project.")); | |
| 248 | }; | |
| 249 | Ok(Outcome::Ok(Place { scope: "project", owner: project.id, namespace: repo.namespace, slug: project.slug })) | |
| 250 | } | |
| 251 | (None, Some(slug)) => { | |
| 252 | let slug = slug.to_lowercase(); | |
| 253 | let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role); | |
| 254 | match role { | |
| 255 | None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))), | |
| 256 | Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))), | |
| 257 | Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug, slug: String::new() })), | |
| 258 | } | |
| 259 | } | |
| 260 | (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")), | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | /// A repository's primary project, with the rows kept under the | |
| 265 | /// repository before projects moved to it. | |
| 266 | pub(crate) async fn project_of(&self, repo_id: &str) -> Result<Option<ProjectRef>> { | |
| 267 | let projects: Vec<ProjectRef> = | |
| 268 | g1t_kit::call(&self.projects, "by_repo", &ByRepoArgs { repo_id: repo_id.to_owned() }).await?; | |
| 269 | let Some(project) = projects.into_iter().find(|p| p.primary) else { | |
| 270 | return Ok(None); | |
| 271 | }; | |
| 272 | self.db | |
| 273 | .prepare("UPDATE settings SET owner = ?, scope = 'project' WHERE owner = ?") | |
| 274 | .bind(&[project.id.as_str().into(), repo_id.into()])? | |
| 275 | .run() | |
| 276 | .await?; | |
| 277 | Ok(Some(project)) | |
| 278 | } | |
| 279 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 280 | /// The environments a repository's own secrets and variables name. |
| 281 | pub(crate) async fn secret_environments(&self, repo_id: &str) -> Result<Vec<String>> { | |
| 282 | let Some(project) = self.project_of(repo_id).await? else { | |
| 283 | return Ok(Vec::new()); | |
| 284 | }; | |
| 285 | let mut out: Vec<String> = Vec::new(); | |
| 286 | for row in self.rows(&project.id).await? { | |
| 287 | for environment in row.environments() { | |
| 288 | if !out.contains(&environment) { | |
| 289 | out.push(environment); | |
| 290 | } | |
| 291 | } | |
| 292 | } | |
| 293 | Ok(out) | |
| 294 | } | |
| 295 | ||
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 296 | /// `secret`, `variable`, or `None` for both. |
| 297 | fn kind(kind: &str) -> Outcome<Option<&'static str>> { | |
| 298 | match kind { | |
| 299 | "secret" | "secrets" => Outcome::Ok(Some("secret")), | |
| 300 | "variable" | "variables" | "config" => Outcome::Ok(Some("variable")), | |
| 301 | "" | "all" => Outcome::Ok(None), | |
| 302 | _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."), | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | async fn rows(&self, owner: &str) -> Result<Vec<SettingRow>> { | |
| 307 | self.db | |
| 308 | .prepare("SELECT * FROM settings WHERE owner = ? ORDER BY name, environments") | |
| 309 | .bind(&[owner.into()])? | |
| 310 | .all() | |
| 311 | .await? | |
| 312 | .results::<SettingRow>() | |
| 313 | } | |
| 314 | ||
| 315 | pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> { | |
| 316 | let kind = check!(Self::kind(&a.kind)); | |
| 317 | let place = check!(self.place(&a.actor, &a.owner, false).await?); | |
| 318 | let mut out: Vec<Setting> = Vec::new(); | |
| 319 | // A project's list shows the workspace's rows that reach it, but for | |
| 320 | // keys it sets itself. | |
| 321 | if place.scope == "project" { | |
| 322 | let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect(); | |
| 323 | for row in self.rows(&place.namespace.to_lowercase()).await? { | |
| 324 | if row.reaches(&place.slug) && !own.contains(&row.name) { | |
| 325 | out.push(row.describe()); | |
| 326 | } | |
| 327 | } | |
| 328 | } | |
| 329 | out.extend(self.rows(&place.owner).await?.into_iter().map(SettingRow::describe)); | |
| 330 | out.retain(|setting| kind.is_none_or(|kind| setting.kind == kind)); | |
| 331 | out.sort_by(|a, b| a.name.cmp(&b.name).then(a.environments.cmp(&b.environments))); | |
| 332 | Ok(Outcome::Ok(out)) | |
| 333 | } | |
| 334 | ||
| 335 | fn seal(&self, value: &str, id: &str) -> Outcome<String> { | |
| 336 | match &self.sealer { | |
| 337 | Some(sealer) => Outcome::Ok(sealer.seal(value, id)), | |
| 338 | None => fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."), | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> { | |
| 343 | let Some(kind) = check!(Self::kind(&a.kind)) else { | |
| 344 | return Ok(fail(FailureCode::Invalid, "`kind` is `secret` or `variable`.")); | |
| 345 | }; | |
| 346 | let name = match valid_name(&a.name) { | |
| 347 | Ok(name) => name, | |
| 348 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 349 | }; | |
| 350 | if a.value.as_ref().is_some_and(|v| v.len() > MAX_VALUE_BYTES) { | |
| 351 | return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB.")); | |
| 352 | } | |
| 353 | if a.note.as_ref().is_some_and(|n| n.len() > MAX_NOTE) { | |
| 354 | return Ok(fail(FailureCode::Invalid, "A note is at most 500 characters.")); | |
| 355 | } | |
| 356 | let readers = match a.available_to.as_deref().map(consumers).transpose() { | |
| 357 | Ok(readers) => readers, | |
| 358 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 359 | }; | |
| 360 | let environments = match a.environments.as_deref().map(valid_environments).transpose() { | |
| 361 | Ok(environments) => environments, | |
| 362 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 363 | }; | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 364 | let brokered = match a.brokered_hosts.as_deref().map(valid_hosts).transpose() { |
| 365 | Ok(hosts) => hosts, | |
| 366 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 367 | }; | |
| 368 | if kind == "variable" && brokered.as_ref().is_some_and(|hosts| !hosts.is_empty()) { | |
| 369 | return Ok(fail(FailureCode::Invalid, "Only a secret can be brokered.")); | |
| 370 | } | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 371 | let place = check!(self.place(&a.actor, &a.owner, true).await?); |
| 372 | if a.projects.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" { | |
| 373 | return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose projects.")); | |
| 374 | } | |
| 375 | let rows = self.rows(&place.owner).await?; | |
| 376 | // A secret and a variable may share a key, as on GitHub, where | |
| 377 | // workflows read them apart (`secrets.X`, `vars.X`). | |
| 378 | let same_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect(); | |
| 379 | // The row being changed: by id, else the key's row for every | |
| 380 | // environment (GitHub's API names a secret by its key alone). | |
| 381 | let existing = match &a.id { | |
| 382 | Some(id) => match rows.iter().find(|row| &row.id == id) { | |
| 383 | Some(row) => Some(row), | |
| 384 | None => return Ok(fail(FailureCode::NotFound, "There is no such row.")), | |
| 385 | }, | |
| 386 | None if a.environments.is_none() => same_key.iter().copied().find(|row| row.environments.is_empty() && row.kind == kind), | |
| 387 | None => None, | |
| 388 | }; | |
| 389 | if existing.is_some_and(|row| row.kind == "secret" && kind == "variable") { | |
| 390 | return Ok(fail(FailureCode::Invalid, "A secret cannot become config: its value is sealed. Add a config row and remove the secret.")); | |
| 391 | } | |
| 392 | let environments = environments.unwrap_or_else(|| existing.map(SettingRow::environments).unwrap_or_default()); | |
| 393 | // A key's rows never apply to the same environment twice. | |
| 394 | if let Some(clash) = same_key | |
| 395 | .iter() | |
| 396 | .find(|row| row.kind == kind && existing.is_none_or(|e| e.id != row.id) && row.overlaps(&environments)) | |
| 397 | { | |
| 398 | let at = if clash.environments.is_empty() { "all environments".to_owned() } else { clash.environments.replace(',', ", ") }; | |
| 399 | let what = if kind == "secret" { "secret" } else { "config" }; | |
| 400 | return Ok(fail( | |
| 401 | FailureCode::Conflict, | |
| 402 | format!("{name} already has a {what} row for {at}. Edit that row, or choose other environments."), | |
| 403 | )); | |
| 404 | } | |
| 405 | if existing.is_none() && rows.len() as u32 >= MAX_PER_OWNER { | |
| 406 | return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} secrets and variables here."))); | |
| 407 | } | |
| 408 | let id = existing.map(|row| row.id.clone()).unwrap_or_else(|| new_id("set", now_ms())); | |
| 409 | let value = match (&a.value, existing) { | |
| 410 | (Some(value), _) if kind == "secret" => check!(self.seal(value, &id)), | |
| 411 | (Some(value), _) => value.clone(), | |
| 412 | // Config becoming a secret: its value is sealed now. | |
| 413 | (None, Some(row)) if row.kind == "variable" && kind == "secret" => check!(self.seal(&row.value, &id)), | |
| 414 | (None, Some(row)) => row.value.clone(), | |
| 415 | (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")), | |
| 416 | }; | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 417 | let brokered: Vec<String> = match brokered { |
| 418 | Some(hosts) => hosts, | |
| 419 | None => existing.filter(|_| kind == "secret").map(SettingRow::brokered_hosts).unwrap_or_default(), | |
| 420 | }; | |
| 421 | // A new brokered secret is read by workflows alone, unless it says | |
| 422 | // otherwise, which is refused below. | |
| 423 | let default_readers = if brokered.is_empty() { CONSUMERS.join(",") } else { "workflows".to_owned() }; | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 424 | let available_to = readers |
| 425 | .map(|r| r.join(",")) | |
| 426 | .or_else(|| existing.map(|row| row.available_to.clone())) | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 427 | .unwrap_or(default_readers); |
| 428 | if !brokered.is_empty() { | |
| 429 | if split(&available_to).iter().any(|r| r == "deployments") { | |
| 430 | return Ok(fail( | |
| 431 | FailureCode::Invalid, | |
| 432 | "A brokered secret is read by workflows only: a deployment's app needs the value itself. Leave Deployments out, or stop brokering it.", | |
| 433 | )); | |
| 434 | } | |
| 435 | // Too short a value would be found inside other secrets. | |
| 436 | let plain = match (&a.value, existing) { | |
| 437 | (Some(value), _) => Some(value.clone()), | |
| 438 | (None, Some(row)) if row.kind == "secret" => self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)), | |
| 439 | (None, Some(row)) => Some(row.value.clone()), | |
| 440 | (None, None) => None, | |
| 441 | }; | |
| 442 | if plain.is_some_and(|value| value.trim().chars().count() < MIN_VALUE_CHARS) { | |
| 443 | return Ok(fail(FailureCode::Invalid, format!("A brokered secret's value is at least {MIN_VALUE_CHARS} characters."))); | |
| 444 | } | |
| 445 | } | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 446 | let repositories: Option<String> = match &a.projects { |
| 447 | Some(list) if list.is_empty() => None, | |
| 448 | Some(list) => Some(serde_json::to_string(list).unwrap_or_default()), | |
| 449 | None => existing.and_then(|row| row.repositories.clone()), | |
| 450 | }; | |
| 451 | let note = match &a.note { | |
| 452 | Some(note) if note.trim().is_empty() => None, | |
| 453 | Some(note) => Some(note.trim().to_owned()), | |
| 454 | None => existing.and_then(|row| row.note.clone()), | |
| 455 | }; | |
| 456 | let at = rfc3339(now_ms()); | |
| 457 | let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from); | |
| 458 | self.db | |
| 459 | .prepare( | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 460 | "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by, brokered_hosts) |
| 461 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 462 | ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at, |
| 463 | available_to = excluded.available_to, environments = excluded.environments, | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 464 | repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by, |
| 465 | brokered_hosts = excluded.brokered_hosts", | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 466 | ) |
| 467 | .bind(&[ | |
| 468 | id.as_str().into(), | |
| 469 | place.scope.into(), | |
| 470 | place.owner.as_str().into(), | |
| 471 | kind.into(), | |
| 472 | name.as_str().into(), | |
| 473 | value.into(), | |
| 474 | at.as_str().into(), | |
| 475 | available_to.as_str().into(), | |
| 476 | environments.join(",").into(), | |
| 477 | optional(repositories.as_deref()), | |
| 478 | optional(note.as_deref()), | |
| 479 | a.actor.username.as_str().into(), | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 480 | optional((!brokered.is_empty()).then(|| serde_json::to_string(&brokered).unwrap_or_default()).as_deref()), |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 481 | ])? |
| 482 | .run() | |
| 483 | .await?; | |
| 484 | let row = self | |
| 485 | .db | |
| 486 | .prepare("SELECT * FROM settings WHERE id = ?") | |
| 487 | .bind(&[id.as_str().into()])? | |
| 488 | .first::<SettingRow>(None) | |
| 489 | .await? | |
| 490 | .expect("just written"); | |
| 491 | Ok(Outcome::Ok(row.describe())) | |
| 492 | } | |
| 493 | ||
| 494 | pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> { | |
| 495 | let kind = check!(Self::kind(&a.kind)); | |
| 496 | let place = check!(self.place(&a.actor, &a.owner, true).await?); | |
| 497 | let name = a.name.trim().to_ascii_uppercase(); | |
| 498 | let removed = match &a.id { | |
| 499 | Some(id) => self | |
| 500 | .db | |
| 501 | .prepare("DELETE FROM settings WHERE owner = ? AND id = ? RETURNING id") | |
| 502 | .bind(&[place.owner.as_str().into(), id.as_str().into()])? | |
| 503 | .all() | |
| 504 | .await?, | |
| 505 | None => self | |
| 506 | .db | |
| 507 | .prepare("DELETE FROM settings WHERE owner = ? AND name = ? AND (?3 IS NULL OR kind = ?3) RETURNING id") | |
| 508 | .bind(&[place.owner.as_str().into(), name.as_str().into(), kind.map_or(JsValue::NULL, JsValue::from)])? | |
| 509 | .all() | |
| 510 | .await?, | |
| 511 | }; | |
| 512 | Ok(if removed.results::<Value>()?.is_empty() { | |
| 513 | fail(FailureCode::NotFound, format!("There is nothing called {} here.", a.name)) | |
| 514 | } else { | |
| 515 | Outcome::Ok(true) | |
| 516 | }) | |
| 517 | } | |
| 518 | ||
| 519 | /// What one reader of a repository gets: per key, the row for | |
| 520 | /// `environment`, else the row for every environment; the repository's | |
| 521 | /// over its workspace's. No secrets unless `trusted`. | |
| 522 | #[allow(clippy::too_many_arguments)] | |
| 523 | async fn resolved( | |
| 524 | &self, | |
| 525 | project_id: &str, | |
| 526 | project_slug: &str, | |
| 527 | namespace: &str, | |
| 528 | kind: &str, | |
| 529 | consumer: &str, | |
| 530 | environment: Option<&str>, | |
| 531 | trusted: bool, | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 532 | ) -> Result<Resolved> { |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 533 | if kind == "secret" && !trusted { |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 534 | return Ok(Resolved::default()); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 535 | } |
| 536 | let environment = environment.map(str::to_ascii_lowercase); | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 537 | let mut out = Resolved::default(); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 538 | for owner in [namespace.to_lowercase(), project_id.to_owned()] { |
| 539 | let rows: Vec<SettingRow> = self | |
| 540 | .rows(&owner) | |
| 541 | .await? | |
| 542 | .into_iter() | |
| 543 | .filter(|row| row.kind == kind) | |
| 544 | .filter(|row| split(&row.available_to).iter().any(|r| r == consumer)) | |
| 545 | .filter(|row| row.scope != "workspace" || row.reaches(project_slug)) | |
| 546 | .collect(); | |
| 547 | let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect(); | |
| 548 | names.dedup(); | |
| 549 | for name in names { | |
| 550 | let of_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect(); | |
| 551 | let chosen = environment | |
| 552 | .as_deref() | |
| 553 | .and_then(|env| of_key.iter().find(|row| row.environments().iter().any(|e| e == env))) | |
| 554 | .or_else(|| of_key.iter().find(|row| row.environments.is_empty())); | |
| 555 | let Some(row) = chosen else { | |
| 556 | // Rows only for other environments: this reader gets | |
| 557 | // none, nor the workspace's. | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 558 | out.values.remove(name); |
| 559 | out.brokered.remove(name); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 560 | continue; |
| 561 | }; | |
| 562 | let value = if kind == "secret" { | |
| 563 | match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) { | |
| 564 | Some(value) => value, | |
| 565 | None => continue, | |
| 566 | } | |
| 567 | } else { | |
| 568 | row.value.clone() | |
| 569 | }; | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 570 | out.values.insert(name.to_owned(), Value::String(value)); |
| 571 | // The row that wins decides whether the key is brokered. | |
| 572 | match row.brokered_hosts() { | |
| 573 | hosts if kind == "secret" && !hosts.is_empty() => out.brokered.insert(name.to_owned(), hosts), | |
| 574 | _ => out.brokered.remove(name), | |
| 575 | }; | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 576 | } |
| 577 | } | |
| 578 | Ok(out) | |
| 579 | } | |
| 580 | ||
| 581 | /// The `vars` context of a repository's runs. `environment` is the job's | |
| 582 | /// `environment:`, when it has one. | |
| 583 | pub async fn variables_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> { | |
| 584 | let (namespace, _) = repo.split_once('/').unwrap_or((repo, "")); | |
| 585 | let Some(project) = self.project_of(repo_id).await? else { | |
| 586 | return Ok(Map::new()); | |
| 587 | }; | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 588 | Ok(self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await?.values) |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 589 | } |
| 590 | ||
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 591 | /// The `secrets` context of a repository's runs, opened, with which of |
| 592 | /// them are brokered: `job_spec` gives the job stand-ins for those. | |
| 593 | pub(crate) async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Resolved> { | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 594 | let (namespace, _) = repo.split_once('/').unwrap_or((repo, "")); |
| 595 | let Some(project) = self.project_of(repo_id).await? else { | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 596 | return Ok(Resolved::default()); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 597 | }; |
| 598 | self.resolved(&project.id, &project.slug, namespace, "secret", "workflows", environment, trusted).await | |
| 599 | } | |
| 600 | ||
| 601 | /// `resolve_settings`, for the deployments service: what a deploy build | |
| 602 | /// and its running app get. | |
| 603 | pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> { | |
| 604 | let environment = a.environment.as_deref(); | |
| 605 | // Rows kept under the repository from before projects move to its | |
| 606 | // primary project first, however the project is named here. | |
| 607 | let primary = self.project_of(&a.repo_id).await?; | |
| 608 | let (project_id, slug) = match (a.project_id, a.project_slug, primary) { | |
| 609 | (Some(id), Some(slug), _) => (id, slug), | |
| 610 | (_, _, Some(project)) => (project.id, project.slug), | |
| 611 | _ => return Ok(ResolvedSettings::default()), | |
| 612 | }; | |
| 613 | Ok(ResolvedSettings { | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 614 | // A brokered secret's value goes only to workflow jobs, as a |
| 615 | // stand-in; a version update's registries go without it. | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 616 | secrets: self |
| 617 | .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted) | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 618 | .await? |
| 619 | .unbrokered(), | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 620 | variables: self |
| 621 | .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted) | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 622 | .await? |
| 623 | .values, | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 624 | }) |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 628 | #[cfg(test)] | |
| 629 | mod tests { | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 630 | use std::collections::BTreeMap; |
| 631 | ||
| 632 | use serde_json::json; | |
| 633 | ||
| 634 | use super::{Resolved, SettingRow, consumers, valid_environments, valid_hosts, valid_name}; | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 635 | |
| 636 | fn row(environments: &str) -> SettingRow { | |
| 637 | SettingRow { | |
| 638 | id: "set_1".into(), | |
| 639 | scope: "repository".into(), | |
| 640 | kind: "secret".into(), | |
| 641 | name: "STRIPE_KEY".into(), | |
| 642 | value: String::new(), | |
| 643 | updated_at: String::new(), | |
| 644 | available_to: "workflows,deployments".into(), | |
| 645 | environments: environments.into(), | |
| 646 | repositories: None, | |
| 647 | note: None, | |
| 648 | updated_by: None, | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 649 | brokered_hosts: None, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 650 | } |
| 651 | } | |
| 652 | ||
| 653 | #[test] | |
| 654 | fn names_follow_githubs_rules_and_keep_g1ts_own() { | |
| 655 | assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN"); | |
| 656 | assert!(valid_name("GITHUB_TOKEN").is_err()); | |
| 657 | assert!(valid_name("G1T_TOKEN").is_err()); | |
| 658 | assert!(valid_name("1PASSWORD").is_err()); | |
| 659 | assert!(valid_name("MY-TOKEN").is_err()); | |
| 660 | assert!(valid_name("").is_err()); | |
| 661 | } | |
| 662 | ||
| 663 | #[test] | |
| 664 | fn environments_and_readers_are_checked() { | |
| 665 | assert_eq!(valid_environments(&["Production".into(), "preview".into(), "production".into()]).unwrap(), vec!["preview", "production"]); | |
| 666 | assert!(valid_environments(&["staging env".into()]).is_err()); | |
| 667 | assert_eq!(consumers(&["Deployments".into(), "deployments".into()]).unwrap(), vec!["deployments"]); | |
| 668 | assert!(consumers(&["agents".into()]).is_err()); | |
| 669 | assert!(consumers(&[]).is_err()); | |
| 670 | } | |
| 671 | ||
| 672 | #[test] | |
| 673 | fn a_keys_rows_cannot_share_an_environment() { | |
| 674 | assert!(row("production").overlaps(&["production".into(), "preview".into()])); | |
| 675 | assert!(!row("production").overlaps(&["preview".into()])); | |
| 676 | // One row for every environment, and others for some, live together. | |
| 677 | assert!(!row("").overlaps(&["preview".into()])); | |
| 678 | assert!(row("").overlaps(&[])); | |
| 679 | } | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 680 | |
| 681 | #[test] | |
| 682 | fn brokered_hosts_are_domains_each_once() { | |
| 683 | let hosts = valid_hosts(&["HTTPS://Registry.npmjs.org/".into(), "*.example.com".into(), "registry.npmjs.org".into(), " ".into()]).unwrap(); | |
| 684 | assert_eq!(hosts, ["registry.npmjs.org", "*.example.com"]); | |
| 685 | assert!(valid_hosts(&["localhost".into()]).is_err()); | |
| 686 | assert!(valid_hosts(&(0..21).map(|n| format!("h{n}.example.com")).collect::<Vec<_>>()).is_err()); | |
| 687 | assert!(valid_hosts(&[]).unwrap().is_empty()); | |
| 688 | } | |
| 689 | ||
| 690 | #[test] | |
| 691 | fn readers_outside_workflows_never_get_a_brokered_value() { | |
| 692 | let resolved = Resolved { | |
| 693 | values: json!({ "NPM_TOKEN": "npm_realvalue123", "PLAIN": "value" }).as_object().cloned().unwrap(), | |
| 694 | brokered: BTreeMap::from([("NPM_TOKEN".to_owned(), vec!["registry.npmjs.org".to_owned()])]), | |
| 695 | }; | |
| 696 | let brokers = resolved.brokers(); | |
| 697 | assert_eq!(brokers.len(), 1); | |
| 698 | assert_eq!(brokers[0].value, "npm_realvalue123"); | |
| 699 | let plain = resolved.unbrokered(); | |
| 700 | assert!(!plain.contains_key("NPM_TOKEN")); | |
| 701 | assert_eq!(plain["PLAIN"], "value"); | |
| 702 | } | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 703 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.