Skip to content

Commit 4fa1081

Browse files
committed
fix(node): split absent ref from unreadable object in issue reads
get_issue folded every cat-file failure into Ok(None), so a corrupt or unreadable object store looked identical to a missing issue. close_issue then hit that None right after resolve_issue_id had proven the ref exists and panicked on the expect. A shared read_issue_blob helper now re-resolves the ref with rev-parse (which does not read the object) when cat-file fails: unresolved means genuinely absent (Ok(None)), resolved means a real read failure (propagated as Err). list_issues applies the same split so a degraded store errors the listing instead of silently under-reporting, and close_issue treats a ref deleted between resolve and read as not-found instead of panicking. Closes #426.
1 parent bfc44f9 commit 4fa1081

2 files changed

Lines changed: 310 additions & 24 deletions

File tree

‎crates/gitlawb-node/src/api/issues.rs‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,198 @@ mod lock_pool_shed_tests {
449449
);
450450
}
451451
}
452+
453+
/// #426: end-to-end at the handler seam. A corrupt object store behind a
454+
/// resolving issue ref used to look identical to an absent issue: get returned
455+
/// a fake 404, close returned a fake 404 after releasing the write guard, and
456+
/// list silently under-reported. All three must now surface a git error.
457+
#[cfg(test)]
458+
mod corrupt_store_tests {
459+
use super::*;
460+
use axum::response::IntoResponse;
461+
use sqlx::PgPool;
462+
use tempfile::TempDir;
463+
464+
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
465+
let now = Utc::now();
466+
crate::db::RepoRecord {
467+
id: Uuid::new_v4().to_string(),
468+
name: name.to_string(),
469+
owner_did: owner_did.to_string(),
470+
description: None,
471+
is_public: true,
472+
default_branch: "main".to_string(),
473+
created_at: now,
474+
updated_at: now,
475+
disk_path: format!("/tmp/{name}"),
476+
forked_from: None,
477+
machine_id: None,
478+
}
479+
}
480+
481+
/// A state whose repo store roots in `repos_dir`, so the test can lay down
482+
/// a real repo and then corrupt it without touching /tmp.
483+
async fn state_with_repos_dir(pool: &PgPool, repos_dir: &std::path::Path) -> AppState {
484+
let mut state = crate::test_support::test_state(pool.clone()).await;
485+
state.repo_store =
486+
crate::git::repo_store::RepoStore::for_testing(repos_dir.to_path_buf(), pool.clone());
487+
state
488+
}
489+
490+
/// Bare repo at the store's expected path, holding one issue ref.
491+
fn bare_repo_with_issue(
492+
repos_dir: &std::path::Path,
493+
owner: &str,
494+
name: &str,
495+
) -> (std::path::PathBuf, String) {
496+
let repo_path = repos_dir
497+
.join(owner.replace([':', '/'], "_"))
498+
.join(format!("{name}.git"));
499+
std::fs::create_dir_all(&repo_path).unwrap();
500+
let out = std::process::Command::new("git")
501+
.args(["init", "--bare"])
502+
.arg(&repo_path)
503+
.output()
504+
.unwrap();
505+
assert!(out.status.success());
506+
let issue_id = "aaaabbbb-0000-0000-0000-000000000000".to_string();
507+
git_issues::create_issue(
508+
&repo_path,
509+
&issue_id,
510+
&serde_json::json!({
511+
"id": issue_id,
512+
"title": "t",
513+
"status": "open",
514+
})
515+
.to_string(),
516+
)
517+
.unwrap();
518+
(repo_path, issue_id)
519+
}
520+
521+
/// Delete the blob object behind the issue ref and put garbage in its
522+
/// place: `cat-file` fails while `rev-parse` still resolves the ref.
523+
fn corrupt_issue_object(repo_path: &std::path::Path, issue_id: &str) {
524+
let out = std::process::Command::new("git")
525+
.args(["rev-parse", &format!("refs/gitlawb/issues/{issue_id}")])
526+
.current_dir(repo_path)
527+
.output()
528+
.unwrap();
529+
let oid = String::from_utf8(out.stdout).unwrap().trim().to_string();
530+
let obj = repo_path.join("objects").join(&oid[..2]).join(&oid[2..]);
531+
std::fs::remove_file(&obj).unwrap();
532+
std::fs::write(&obj, b"garbage").unwrap();
533+
}
534+
535+
/// The regression: closing over a corrupt store used to return a fake 404
536+
/// (or panic deeper in). It must reach the caller as a git 500, and the
537+
/// handler must return rather than unwind.
538+
#[sqlx::test]
539+
async fn close_issue_on_corrupt_store_returns_git_error_not_panic(pool: PgPool) {
540+
let owner = "did:key:zISSUECORRUPTCCCCCCCCCCCCCCCCCCCCCCCCC";
541+
let dir = TempDir::new().unwrap();
542+
let state = state_with_repos_dir(&pool, dir.path()).await;
543+
state
544+
.db
545+
.create_repo(&seed_repo(owner, "corrupt-close"))
546+
.await
547+
.expect("seed repo");
548+
let (repo_path, issue_id) = bare_repo_with_issue(dir.path(), owner, "corrupt-close");
549+
corrupt_issue_object(&repo_path, &issue_id);
550+
551+
let err = close_issue(
552+
State(state),
553+
Extension(AuthenticatedDid(owner.to_string())),
554+
Path((owner.to_string(), "corrupt-close".to_string(), issue_id)),
555+
)
556+
.await
557+
.expect_err("a corrupt store must error, not panic or 404");
558+
assert_eq!(
559+
err.into_response().status(),
560+
StatusCode::INTERNAL_SERVER_ERROR,
561+
"a read failure is a git 500, not a fake not-found"
562+
);
563+
}
564+
565+
/// get on a corrupt store: 500, not the 404 the fold used to produce.
566+
#[sqlx::test]
567+
async fn get_issue_on_corrupt_store_returns_500_not_404(pool: PgPool) {
568+
let owner = "did:key:zISSUECORRUPTGETDDDDDDDDDDDDDDDDDDDDDDD";
569+
let dir = TempDir::new().unwrap();
570+
let state = state_with_repos_dir(&pool, dir.path()).await;
571+
state
572+
.db
573+
.create_repo(&seed_repo(owner, "corrupt-get"))
574+
.await
575+
.expect("seed repo");
576+
let (repo_path, issue_id) = bare_repo_with_issue(dir.path(), owner, "corrupt-get");
577+
corrupt_issue_object(&repo_path, &issue_id);
578+
579+
let err = get_issue(
580+
State(state),
581+
Path((owner.to_string(), "corrupt-get".to_string(), issue_id)),
582+
None,
583+
)
584+
.await
585+
.expect_err("a corrupt store must error, not 404");
586+
assert_eq!(
587+
err.into_response().status(),
588+
StatusCode::INTERNAL_SERVER_ERROR,
589+
);
590+
}
591+
592+
/// list on a corrupt store must fail outright; a 200 that silently drops
593+
/// the unreadable issue is the other half of the fold.
594+
#[sqlx::test]
595+
async fn list_issues_on_corrupt_store_returns_git_error(pool: PgPool) {
596+
let owner = "did:key:zISSUECORRUPTLISTEEEEEEEEEEEEEEEEEEEEEE";
597+
let dir = TempDir::new().unwrap();
598+
let state = state_with_repos_dir(&pool, dir.path()).await;
599+
state
600+
.db
601+
.create_repo(&seed_repo(owner, "corrupt-list"))
602+
.await
603+
.expect("seed repo");
604+
let (repo_path, issue_id) = bare_repo_with_issue(dir.path(), owner, "corrupt-list");
605+
corrupt_issue_object(&repo_path, &issue_id);
606+
607+
let err = list_issues(
608+
State(state),
609+
Path((owner.to_string(), "corrupt-list".to_string())),
610+
None,
611+
)
612+
.await
613+
.expect_err("a corrupt store must error, not under-report");
614+
assert_eq!(
615+
err.into_response().status(),
616+
StatusCode::INTERNAL_SERVER_ERROR,
617+
);
618+
}
619+
620+
/// Must-not direction: a genuinely absent issue still gets the 404.
621+
#[sqlx::test]
622+
async fn get_issue_on_absent_ref_still_returns_404(pool: PgPool) {
623+
let owner = "did:key:zISSUEABSENTFFFFFFFFFFFFFFFFFFFFFFFFFFF";
624+
let dir = TempDir::new().unwrap();
625+
let state = state_with_repos_dir(&pool, dir.path()).await;
626+
state
627+
.db
628+
.create_repo(&seed_repo(owner, "absent-get"))
629+
.await
630+
.expect("seed repo");
631+
bare_repo_with_issue(dir.path(), owner, "absent-get");
632+
633+
let err = get_issue(
634+
State(state),
635+
Path((
636+
owner.to_string(),
637+
"absent-get".to_string(),
638+
"ffffffff-0000-0000-0000-000000000000".to_string(),
639+
)),
640+
None,
641+
)
642+
.await
643+
.expect_err("a missing issue is still not-found");
644+
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
645+
}
646+
}

‎crates/gitlawb-node/src/git/issues.rs‎

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,44 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
6161
Ok(())
6262
}
6363

64+
/// Read the blob behind an issue ref.
65+
///
66+
/// `Ok(None)` means the ref itself is absent. A ref that resolves but whose
67+
/// object cannot be read (corrupt object, unreadable store, a mid-read gc) is
68+
/// an error, not an absence: `git cat-file` exits nonzero for both cases, so
69+
/// the ref is re-resolved to tell them apart. `rev-parse` resolves the name
70+
/// without reading the object, which is exactly the split needed (#426).
71+
fn read_issue_blob(repo_path: &Path, ref_name: &str) -> Result<Option<String>> {
72+
// allow-unbounded-git: this module's spawns predate the bounded runner and
73+
// its callers run inside issue handlers that already hold the repo guard.
74+
let cat_output = Command::new("git")
75+
.args(["cat-file", "blob", ref_name])
76+
.current_dir(repo_path)
77+
.output()
78+
.context("failed to run git cat-file")?;
79+
80+
if cat_output.status.success() {
81+
return Ok(Some(
82+
String::from_utf8_lossy(&cat_output.stdout).to_string(),
83+
));
84+
}
85+
86+
// allow-unbounded-git: failure-path existence recheck, at most one extra
87+
// spawn per failed read.
88+
let resolves = Command::new("git")
89+
.args(["rev-parse", "--verify", "--quiet", ref_name])
90+
.current_dir(repo_path)
91+
.output()
92+
.context("failed to run git rev-parse")?;
93+
94+
if !resolves.status.success() {
95+
return Ok(None);
96+
}
97+
98+
let stderr = String::from_utf8_lossy(&cat_output.stderr);
99+
anyhow::bail!("git cat-file failed for {ref_name}: {}", stderr.trim());
100+
}
101+
64102
/// List all issue refs and return their JSON content.
65103
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
66104
// List all refs under refs/gitlawb/issues/
@@ -88,16 +126,12 @@ pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
88126
continue;
89127
}
90128

91-
// Read the blob content
92-
let cat_output = Command::new("git")
93-
.args(["cat-file", "blob", ref_name])
94-
.current_dir(repo_path)
95-
.output()
96-
.context("failed to run git cat-file")?;
97-
98-
if cat_output.status.success() {
99-
let content = String::from_utf8_lossy(&cat_output.stdout).to_string();
100-
issues.push(content);
129+
// Read the blob content. A ref that vanished between for-each-ref and
130+
// here is skipped; a ref that resolves but fails to read propagates so
131+
// a degraded object store errors the listing instead of under-reporting.
132+
match read_issue_blob(repo_path, ref_name)? {
133+
Some(content) => issues.push(content),
134+
None => continue,
101135
}
102136
}
103137

@@ -161,8 +195,12 @@ pub fn close_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
161195
None => return Ok(None),
162196
};
163197

164-
let raw = get_issue(repo_path, &full_id)?
165-
.expect("ref existed in resolve but not in get — should be impossible");
198+
// A ref that resolved and is now absent was deleted between the two calls;
199+
// any other read failure propagates as an error rather than panic (#426).
200+
let raw = match get_issue(repo_path, &full_id)? {
201+
Some(raw) => raw,
202+
None => return Ok(None),
203+
};
166204

167205
let mut issue: serde_json::Value =
168206
serde_json::from_str(&raw).context("invalid issue JSON in git ref")?;
@@ -181,18 +219,7 @@ pub fn get_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
181219
};
182220

183221
let ref_name = format!("refs/gitlawb/issues/{full_id}");
184-
let cat_output = Command::new("git")
185-
.args(["cat-file", "blob", &ref_name])
186-
.current_dir(repo_path)
187-
.output()
188-
.context("failed to run git cat-file")?;
189-
190-
if !cat_output.status.success() {
191-
return Ok(None);
192-
}
193-
194-
let content = String::from_utf8_lossy(&cat_output.stdout).to_string();
195-
Ok(Some(content))
222+
read_issue_blob(repo_path, &ref_name)
196223
}
197224

198225
#[cfg(test)]
@@ -294,4 +321,68 @@ mod tests {
294321
let v: serde_json::Value = serde_json::from_str(&updated).unwrap();
295322
assert_eq!(v["status"], "closed");
296323
}
324+
325+
/// Overwrite the loose object behind an issue ref with garbage, so
326+
/// `cat-file` fails while `rev-parse` still resolves the ref (#426).
327+
fn corrupt_issue_object(dir: &TempDir, full_id: &str) {
328+
let oid_out = Command::new("git")
329+
.args(["rev-parse", &format!("refs/gitlawb/issues/{full_id}")])
330+
.current_dir(dir.path())
331+
.output()
332+
.unwrap();
333+
let oid = String::from_utf8(oid_out.stdout)
334+
.unwrap()
335+
.trim()
336+
.to_string();
337+
let obj = dir
338+
.path()
339+
.join(".git/objects")
340+
.join(&oid[..2])
341+
.join(&oid[2..]);
342+
std::fs::remove_file(&obj).unwrap();
343+
std::fs::write(&obj, b"garbage").unwrap();
344+
}
345+
346+
#[test]
347+
fn test_get_and_close_missing_issue_return_none() {
348+
let dir = TempDir::new().unwrap();
349+
init_repo(&dir);
350+
assert_eq!(get_issue(dir.path(), "nosuch00").unwrap(), None);
351+
assert_eq!(close_issue(dir.path(), "nosuch00").unwrap(), None);
352+
}
353+
354+
// #426: a ref that resolves but whose object is corrupt is a read error,
355+
// not an absent issue. `close_issue` used to `expect()` on that None and
356+
// panic inside a request handler holding the write guard.
357+
#[test]
358+
fn test_corrupt_issue_object_errors_instead_of_panicking() {
359+
let dir = TempDir::new().unwrap();
360+
init_repo(&dir);
361+
let full_id = "bad00000-0000-0000-0000-000000000000";
362+
create_issue(dir.path(), full_id, r#"{"status":"open"}"#).unwrap();
363+
corrupt_issue_object(&dir, full_id);
364+
365+
assert!(get_issue(dir.path(), full_id).is_err());
366+
assert!(close_issue(dir.path(), full_id).is_err());
367+
assert!(list_issues(dir.path()).is_err());
368+
}
369+
370+
#[test]
371+
fn test_list_issues_returns_all() {
372+
let dir = TempDir::new().unwrap();
373+
init_repo(&dir);
374+
create_issue(
375+
dir.path(),
376+
"aaa00000-0000-0000-0000-000000000000",
377+
r#"{"status":"open"}"#,
378+
)
379+
.unwrap();
380+
create_issue(
381+
dir.path(),
382+
"bbb00000-0000-0000-0000-000000000000",
383+
r#"{"status":"open"}"#,
384+
)
385+
.unwrap();
386+
assert_eq!(list_issues(dir.path()).unwrap().len(), 2);
387+
}
297388
}

0 commit comments

Comments
 (0)