Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/safeoutputs/add_pr_comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
3 changes: 1 addition & 2 deletions src/safeoutputs/create_branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions src/safeoutputs/create_git_tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 8 additions & 6 deletions src/safeoutputs/create_pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
250 changes: 235 additions & 15 deletions src/safeoutputs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
) -> 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<String, ExecutionResult> {
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**
Expand Down Expand Up @@ -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<String, String> {
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<String, String>,
) -> 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
);
}
}
2 changes: 1 addition & 1 deletion src/safeoutputs/reply_to_pr_comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading