From 2489f8b5955ea4445f6f3c753c9667437ff10dad Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 3 Aug 2026 18:33:46 +0200 Subject: [PATCH] fix(cli): fail closed on a 404 after /projects pagination has started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_projects_page maps every 404 to Ok(None), and the pagination loop in resolve_project_by_repo turned that into a clean "no match" on any page. A 404 on page 2+ — Django pagination when a concurrent delete shrinks the filtered set — therefore discarded the exact matches already collected from page 1, and the caller fell back to the checkout basename, letting list/wait query an unrelated same-basename project and exit 0. The page-1 404 stays a soft None: that is the intentional compatibility path for a backend without the /projects endpoint. Any later page is now a hard error. --- src/utils/api.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/utils/api.rs b/src/utils/api.rs index d1e2af0..ae164cd 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -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() { @@ -1750,6 +1760,14 @@ 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()); @@ -1757,12 +1775,12 @@ mod tests { 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()); } }); @@ -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,