Skip to content
Merged
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
43 changes: 38 additions & 5 deletions src/utils/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,17 @@ pub fn resolve_project_by_repo(
let mut page = 1;
loop {
let Some(parsed) = fetch_projects_page(url, repo_path, page)? else {
return Ok(None);
// Only page 1 can be a backend without the endpoint; a 404 once the
// walk is under way (a concurrent delete shrinking the filtered set)
// would discard the matches already found and read as a clean miss.
if page == 1 {
return Ok(None);
}
return Err(format!(
"/projects page {} returned 404 after pagination started",
page
)
.into());
};
let total_pages = parsed.total_pages.unwrap_or(1);
if parsed.projects.is_empty() {
Expand Down Expand Up @@ -1750,19 +1760,27 @@ mod tests {

// Serves `page_one` until a request carries `page=2`, then `page_two`.
fn spawn_paged_projects_stub(page_one: &'static str, page_two: &'static str) -> String {
spawn_paged_projects_stub_status(page_one, "200 OK", page_two)
}

fn spawn_paged_projects_stub_status(
page_one: &'static str,
page_two_status: &'static str,
page_two: &'static str,
) -> String {
use std::io::Write;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub");
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let request = corgea::vuln_api_stub::read_http_request(&mut stream);
let body = if String::from_utf8_lossy(&request).contains("page=2") {
page_two
let (status, body) = if String::from_utf8_lossy(&request).contains("page=2") {
(page_two_status, page_two)
} else {
page_one
("200 OK", page_one)
};
let resp = corgea::vuln_api_stub::http_response("200 OK", "", body);
let resp = corgea::vuln_api_stub::http_response(status, "", body);
let _ = stream.write_all(resp.as_bytes());
}
});
Expand All @@ -1782,6 +1800,21 @@ mod tests {
assert_eq!(got.map(|p| p.name).as_deref(), Some("acme/api"));
}

#[test]
fn resolve_project_by_repo_mid_pagination_404_is_hard_err() {
// Django 404s a page that a concurrent delete shrank out of existence.
// Page 1 already held the exact match, so a soft miss here would throw
// it away and send the caller to the legacy-name fallback.
let base = spawn_paged_projects_stub_status(
r#"{"status":"ok","total_pages":2,"projects":[{"name":"acme/api","repo_url":"https://github.com/acme/api"}]}"#,
"404 Not Found",
r#"{"message":"Invalid page."}"#,
);
let err = resolve_project_by_repo(&base, "acme/api", None).unwrap_err();
assert!(err.to_string().contains("page 2"), "{err}");
assert!(err.to_string().contains("pagination"), "{err}");
}

#[test]
fn resolve_project_by_repo_truncated_search_is_hard_err() {
// The ceiling stops the walk before every reported page was searched,
Expand Down
Loading