diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index d070fbdf..a796f66a 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -183,7 +183,7 @@ When `workspace: root` and multiple repositories are checked out, agents can cre {"title": "Fix in main repo", "description": "...", "repository": "self"} {"title": "Fix in other repo", "description": "...", "repository": "other-repo"} ``` -The `repository` value must be "self" or an alias from the `checkout:` list in the front matter. +The `repository` value must be `"self"`, an alias from the `checkout:` list in the front matter, the full Azure DevOps repository name (e.g. `project/repo`), or the bare repository name (case-insensitive, e.g. `sdk-FtdiDeviceControl` for an entry whose ADO name is `4x4/sdk-FtdiDeviceControl`). ### noop Reports that no action was needed. Use this to provide visibility when analysis is complete but no changes or outputs are required. diff --git a/src/safeoutputs/add_pr_comment.rs b/src/safeoutputs/add_pr_comment.rs index 2c72ad83..aebdc0a8 100644 --- a/src/safeoutputs/add_pr_comment.rs +++ b/src/safeoutputs/add_pr_comment.rs @@ -283,7 +283,10 @@ impl Executor for AddPrCommentResult { .context("BUILD_REPOSITORY_NAME not set and repository is 'self'")? .clone() } else { - match ctx.allowed_repositories.get(&self.repository) { + match crate::safeoutputs::lookup_allowed_repository( + &self.repository, + &ctx.allowed_repositories, + ) { Some(name) => name.clone(), None => { return Ok(ExecutionResult::failure(format!( diff --git a/src/safeoutputs/create_branch.rs b/src/safeoutputs/create_branch.rs index 82f67c4e..5d26f988 100644 --- a/src/safeoutputs/create_branch.rs +++ b/src/safeoutputs/create_branch.rs @@ -265,8 +265,7 @@ impl Executor for CreateBranchResult { .context("BUILD_REPOSITORY_NAME not set")? .to_string() } else { - ctx.allowed_repositories - .get(repo_alias) + crate::safeoutputs::lookup_allowed_repository(repo_alias, &ctx.allowed_repositories) .cloned() .context(format!( "Repository alias '{}' is not in the allowed checkout list", diff --git a/src/safeoutputs/create_git_tag.rs b/src/safeoutputs/create_git_tag.rs index be9d1ecc..5f241936 100644 --- a/src/safeoutputs/create_git_tag.rs +++ b/src/safeoutputs/create_git_tag.rs @@ -299,8 +299,7 @@ impl Executor for CreateGitTagResult { .context("BUILD_REPOSITORY_NAME not set and repository is 'self'")? .to_string() } else { - ctx.allowed_repositories - .get(repo_alias) + crate::safeoutputs::lookup_allowed_repository(repo_alias, &ctx.allowed_repositories) .cloned() .context(format!( "Repository alias '{}' not found in allowed repositories", diff --git a/src/safeoutputs/create_pr.rs b/src/safeoutputs/create_pr.rs index 1909b4b3..b45cd743 100644 --- a/src/safeoutputs/create_pr.rs +++ b/src/safeoutputs/create_pr.rs @@ -590,18 +590,20 @@ impl Executor for CreatePrResult { "Validating repository '{}' against allowed list", self.repository ); - let repo_id = if self.repository == "self" { - // "self" uses the pipeline's own repository - debug!("Using 'self' repository"); + let repo_id = if crate::safeoutputs::input_refers_to_self(&self.repository, ctx) { + // "self" or a name match against the pipeline's own repository + debug!("Using 'self' repository (matched '{}')", self.repository); ctx.repository_id .as_ref() .or(ctx.repository_name.as_ref()) .context("Repository ID not configured for 'self'")? .clone() - } else if let Some(ado_repo_name) = ctx.allowed_repositories.get(&self.repository) { - // Alias found in allowed list - use the mapped ADO repo name + } else if let Some(ado_repo_name) = + crate::safeoutputs::lookup_allowed_repository(&self.repository, &ctx.allowed_repositories) + { + // Matched against allowed list (by alias, full value, or trailing name) debug!( - "Repository alias '{}' maps to '{}'", + "Repository '{}' resolved to '{}'", self.repository, ado_repo_name ); ado_repo_name.clone() diff --git a/src/safeoutputs/mod.rs b/src/safeoutputs/mod.rs index 33a85048..fba1fa72 100644 --- a/src/safeoutputs/mod.rs +++ b/src/safeoutputs/mod.rs @@ -184,29 +184,90 @@ pub(crate) async fn resolve_wiki_branch( } } +/// Look up an ADO repo name in `allowed_repositories`, accepting either: +/// 1. an exact alias key (e.g. `repo-sdk-ftdidevicecontrol`), +/// 2. an exact value match against the configured `name` (e.g. `4x4/sdk-FtdiDeviceControl`), or +/// 3. a case-insensitive match against the trailing repo-name part of the value +/// (e.g. `sdk-FtdiDeviceControl` for `4x4/sdk-FtdiDeviceControl`). +/// +/// Azure DevOps repository names are case-insensitive, so the trailing-name fallback +/// matches case-insensitively. Returns the resolved ADO repo name (the map value) on +/// success, or `None` if no entry matches. +pub(crate) fn lookup_allowed_repository<'a>( + input: &str, + allowed_repositories: &'a std::collections::HashMap, +) -> Option<&'a String> { + // 1. Exact alias key match + if let Some(name) = allowed_repositories.get(input) { + return Some(name); + } + // 2. Case-insensitive value match (full "project/repo" or just "repo"). + // ADO repo names are case-insensitive, so accept any case for the full path. + if let Some((_, name)) = allowed_repositories + .iter() + .find(|(_, v)| v.eq_ignore_ascii_case(input)) + { + return Some(name); + } + // 3. Trailing repo-name part match (case-insensitive) + allowed_repositories.iter().find_map(|(_, v)| { + let trailing = v.rsplit('/').next().unwrap_or(v.as_str()); + if trailing.eq_ignore_ascii_case(input) { + Some(v) + } else { + None + } + }) +} + +/// Return `true` if `input` refers to the pipeline's own repository — either the +/// literal string `"self"`, the empty string, or a case-insensitive match against +/// `ctx.repository_name` (full value or trailing repo-name part). +pub(crate) fn input_refers_to_self(input: &str, ctx: &ExecutionContext) -> bool { + if input == "self" || input.is_empty() { + if input.is_empty() { + debug!("Empty repository alias treated as 'self'"); + } + return true; + } + if let Some(name) = ctx.repository_name.as_deref() { + if name.eq_ignore_ascii_case(input) { + return true; + } + let trailing = name.rsplit('/').next().unwrap_or(name); + if trailing.eq_ignore_ascii_case(input) { + return true; + } + } + false +} + /// Resolve a repository alias to its ADO repo name. /// -/// "self" (or None) → `ctx.repository_name`, otherwise look up in `ctx.allowed_repositories`. +/// Accepts `"self"` (or `None`) → `ctx.repository_name`, an alias key from +/// `ctx.allowed_repositories`, an exact value match, or a case-insensitive match +/// against the trailing repo-name part of either `ctx.repository_name` or any +/// configured allowed repository. See [`lookup_allowed_repository`] for the +/// matching rules used against `ctx.allowed_repositories`. pub(crate) fn resolve_repo_name( repo_alias: Option<&str>, ctx: &ExecutionContext, ) -> Result { let alias = repo_alias.unwrap_or("self"); - if alias == "self" { - ctx.repository_name + if input_refers_to_self(alias, ctx) { + return ctx + .repository_name .clone() - .ok_or_else(|| ExecutionResult::failure("BUILD_REPOSITORY_NAME not set")) - } else { - ctx.allowed_repositories - .get(alias) - .cloned() - .ok_or_else(|| { - ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed repository list", - alias - )) - }) - } + .ok_or_else(|| ExecutionResult::failure("BUILD_REPOSITORY_NAME not set")); + } + lookup_allowed_repository(alias, &ctx.allowed_repositories) + .cloned() + .ok_or_else(|| { + ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed repository list", + alias + )) + }) } /// Match a `value` against a `pattern` where `*` matches zero or more of **any** @@ -662,4 +723,163 @@ mod tests { assert!(name_matches_pattern("agent-report", "agent-*")); assert!(!name_matches_pattern("bot-report", "agent-*")); } + + // ─── lookup_allowed_repository ────────────────────────────────────── + + fn sample_allowed() -> std::collections::HashMap { + let mut m = std::collections::HashMap::new(); + m.insert( + "repo-sdk-ftdidevicecontrol".to_string(), + "4x4/sdk-FtdiDeviceControl".to_string(), + ); + m.insert( + "repo-sdk-devicecommunication".to_string(), + "4x4/sdk-DeviceCommunication".to_string(), + ); + m + } + + #[test] + fn test_lookup_allowed_repository_by_alias() { + let m = sample_allowed(); + assert_eq!( + lookup_allowed_repository("repo-sdk-ftdidevicecontrol", &m), + Some(&"4x4/sdk-FtdiDeviceControl".to_string()) + ); + } + + #[test] + fn test_lookup_allowed_repository_by_full_value() { + let m = sample_allowed(); + assert_eq!( + lookup_allowed_repository("4x4/sdk-FtdiDeviceControl", &m), + Some(&"4x4/sdk-FtdiDeviceControl".to_string()) + ); + } + + #[test] + fn test_lookup_allowed_repository_by_trailing_name() { + let m = sample_allowed(); + // Exact case + assert_eq!( + lookup_allowed_repository("sdk-FtdiDeviceControl", &m), + Some(&"4x4/sdk-FtdiDeviceControl".to_string()) + ); + // Case-insensitive (ADO repo names are case-insensitive) + assert_eq!( + lookup_allowed_repository("sdk-ftdidevicecontrol", &m), + Some(&"4x4/sdk-FtdiDeviceControl".to_string()) + ); + assert_eq!( + lookup_allowed_repository("SDK-DEVICECOMMUNICATION", &m), + Some(&"4x4/sdk-DeviceCommunication".to_string()) + ); + } + + #[test] + fn test_lookup_allowed_repository_no_match() { + let m = sample_allowed(); + assert_eq!(lookup_allowed_repository("does-not-exist", &m), None); + // Partial name should not match + assert_eq!(lookup_allowed_repository("sdk", &m), None); + } + + #[test] + fn test_lookup_allowed_repository_no_slash_value() { + let mut m = std::collections::HashMap::new(); + m.insert("alias".to_string(), "PlainName".to_string()); + // Full value + assert_eq!( + lookup_allowed_repository("PlainName", &m), + Some(&"PlainName".to_string()) + ); + // Case-insensitive trailing match + assert_eq!( + lookup_allowed_repository("plainname", &m), + Some(&"PlainName".to_string()) + ); + } + + #[test] + fn test_lookup_allowed_repository_case_insensitive_full_value() { + let m = sample_allowed(); + // Case-insensitive on the full "project/repo" value + assert_eq!( + lookup_allowed_repository("4x4/SDK-FTDIDEVICECONTROL", &m), + Some(&"4x4/sdk-FtdiDeviceControl".to_string()) + ); + assert_eq!( + lookup_allowed_repository("4X4/sdk-ftdidevicecontrol", &m), + Some(&"4x4/sdk-FtdiDeviceControl".to_string()) + ); + } + + // ─── resolve_repo_name ────────────────────────────────────────────── + + fn ctx_with( + repository_name: Option<&str>, + allowed: std::collections::HashMap, + ) -> ExecutionContext { + let mut ctx = ExecutionContext::default(); + ctx.repository_name = repository_name.map(|s| s.to_string()); + ctx.allowed_repositories = allowed; + ctx + } + + #[test] + fn test_resolve_repo_name_self_literal() { + let ctx = ctx_with(Some("4x4/sdk-FtdiDeviceControl"), sample_allowed()); + assert_eq!( + resolve_repo_name(Some("self"), &ctx).unwrap(), + "4x4/sdk-FtdiDeviceControl" + ); + assert_eq!( + resolve_repo_name(None, &ctx).unwrap(), + "4x4/sdk-FtdiDeviceControl" + ); + } + + #[test] + fn test_resolve_repo_name_self_by_repository_name() { + let ctx = ctx_with(Some("4x4/sdk-FtdiDeviceControl"), sample_allowed()); + // Trailing-name match on ctx.repository_name (case-insensitive) + assert_eq!( + resolve_repo_name(Some("sdk-FtdiDeviceControl"), &ctx).unwrap(), + "4x4/sdk-FtdiDeviceControl" + ); + assert_eq!( + resolve_repo_name(Some("sdk-ftdidevicecontrol"), &ctx).unwrap(), + "4x4/sdk-FtdiDeviceControl" + ); + // Full-value match on ctx.repository_name (case-insensitive) + assert_eq!( + resolve_repo_name(Some("4X4/sdk-ftdidevicecontrol"), &ctx).unwrap(), + "4x4/sdk-FtdiDeviceControl" + ); + } + + #[test] + fn test_resolve_repo_name_alias() { + let ctx = ctx_with(Some("4x4/some-other-repo"), sample_allowed()); + assert_eq!( + resolve_repo_name(Some("repo-sdk-devicecommunication"), &ctx).unwrap(), + "4x4/sdk-DeviceCommunication" + ); + // Trailing-name match against allowed list + assert_eq!( + resolve_repo_name(Some("sdk-DeviceCommunication"), &ctx).unwrap(), + "4x4/sdk-DeviceCommunication" + ); + } + + #[test] + fn test_resolve_repo_name_unknown() { + let ctx = ctx_with(Some("4x4/some-other-repo"), sample_allowed()); + let err = resolve_repo_name(Some("does-not-exist"), &ctx).unwrap_err(); + assert!( + err.message.contains("not in the allowed repository list"), + "got: {:?}", + err.message + ); + } } diff --git a/src/safeoutputs/reply_to_pr_comment.rs b/src/safeoutputs/reply_to_pr_comment.rs index 87e0595f..fdcae6ed 100644 --- a/src/safeoutputs/reply_to_pr_comment.rs +++ b/src/safeoutputs/reply_to_pr_comment.rs @@ -154,7 +154,7 @@ impl Executor for ReplyToPrCommentResult { .context("BUILD_REPOSITORY_NAME not set and repository is 'self'")? .clone() } else { - match ctx.allowed_repositories.get(repository) { + match crate::safeoutputs::lookup_allowed_repository(repository, &ctx.allowed_repositories) { Some(name) => name.clone(), None => { return Ok(ExecutionResult::failure(format!(