Skip to content

Commit 5924b4b

Browse files
committed
fix(node): bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth (#147)
1 parent 94bb216 commit 5924b4b

4 files changed

Lines changed: 367 additions & 9 deletions

File tree

crates/gitlawb-node/src/api/certs.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
//! API handlers for ref certificates.
22
3-
use axum::extract::{Path, State};
3+
use std::collections::HashMap;
4+
5+
use axum::extract::{Path, Query, State};
46
use axum::Json;
57

68
use crate::error::{AppError, Result};
79
use crate::state::AppState;
810

9-
/// GET /api/v1/repos/{owner}/{repo}/certs
11+
/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50
1012
pub async fn list_certs(
1113
State(state): State<AppState>,
1214
Path((owner, name)): Path<(String, String)>,
15+
Query(params): Query<HashMap<String, String>>,
1316
) -> Result<Json<serde_json::Value>> {
17+
let limit = params
18+
.get("limit")
19+
.and_then(|v| v.parse::<i64>().ok())
20+
.map(|v| v.max(1))
21+
.unwrap_or(50)
22+
.min(200);
23+
1424
let record = state
1525
.db
1626
.get_repo(&owner, &name)
1727
.await?
1828
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
1929

20-
let certs = state.db.list_ref_certificates(&record.id).await?;
30+
let certs = state.db.list_ref_certificates(&record.id, limit).await?;
2131
let certs_json: Vec<serde_json::Value> = certs
2232
.iter()
2333
.map(|c| {
@@ -35,7 +45,10 @@ pub async fn list_certs(
3545
})
3646
.collect();
3747

38-
Ok(Json(serde_json::json!({ "certificates": certs_json })))
48+
let count = certs_json.len();
49+
Ok(Json(
50+
serde_json::json!({ "certificates": certs_json, "count": count }),
51+
))
3952
}
4053

4154
/// GET /api/v1/repos/{owner}/{repo}/certs/{id}

crates/gitlawb-node/src/api/events.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,11 @@ pub async fn list_repo_events(
7575
};
7676

7777
// Fetch local ref certificates for this repo (if the repo exists on this node)
78+
// Bound to the same limit as the overall response to avoid unbounded reads.
7879
let cert_events: Vec<serde_json::Value> = if let Some(ref record) = repo_record {
7980
state
8081
.db
81-
.list_ref_certificates(&record.id)
82+
.list_ref_certificates(&record.id, limit)
8283
.await
8384
.unwrap_or_default()
8485
.iter()

crates/gitlawb-node/src/db/mod.rs

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,25 @@ const MIGRATIONS: &[Migration] = &[
823823
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS quarantined BOOLEAN NOT NULL DEFAULT FALSE",
824824
],
825825
},
826+
Migration {
827+
version: 10,
828+
name: "ref_cert_unique_per_ref",
829+
stmts: &[
830+
// Dedup before the unique index: keep only the most recent row per
831+
// (repo_id, ref_name) so the CREATE UNIQUE INDEX below does not fail
832+
// on existing databases that accumulated duplicates.
833+
r#"DELETE FROM ref_certificates
834+
WHERE id IN (
835+
SELECT id FROM (
836+
SELECT id, ROW_NUMBER() OVER (
837+
PARTITION BY repo_id, ref_name ORDER BY issued_at DESC
838+
) AS rn
839+
FROM ref_certificates
840+
) dups WHERE dups.rn > 1
841+
)"#,
842+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_ref ON ref_certificates(repo_id, ref_name)",
843+
],
844+
},
826845
];
827846

828847
// ── Repos ─────────────────────────────────────────────────────────────────────
@@ -1909,7 +1928,15 @@ impl Db {
19091928
sqlx::query(
19101929
"INSERT INTO ref_certificates
19111930
(id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at)
1912-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
1931+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
1932+
ON CONFLICT (repo_id, ref_name) DO UPDATE SET
1933+
id = EXCLUDED.id,
1934+
old_sha = EXCLUDED.old_sha,
1935+
new_sha = EXCLUDED.new_sha,
1936+
pusher_did = EXCLUDED.pusher_did,
1937+
node_did = EXCLUDED.node_did,
1938+
signature = EXCLUDED.signature,
1939+
issued_at = EXCLUDED.issued_at",
19131940
)
19141941
.bind(&cert.id)
19151942
.bind(&cert.repo_id)
@@ -1925,12 +1952,17 @@ impl Db {
19251952
Ok(())
19261953
}
19271954

1928-
pub async fn list_ref_certificates(&self, repo_id: &str) -> Result<Vec<RefCertificate>> {
1955+
pub async fn list_ref_certificates(
1956+
&self,
1957+
repo_id: &str,
1958+
limit: i64,
1959+
) -> Result<Vec<RefCertificate>> {
19291960
let rows = sqlx::query(
19301961
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at
1931-
FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC",
1962+
FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC LIMIT $2",
19321963
)
19331964
.bind(repo_id)
1965+
.bind(limit)
19341966
.fetch_all(&self.pool)
19351967
.await?;
19361968
Ok(rows.into_iter().map(row_to_cert).collect())
@@ -4249,3 +4281,150 @@ mod icaptcha_quarantine_tests {
42494281
assert!(with_stars.iter().all(|(r, _)| r.name != "spam"));
42504282
}
42514283
}
4284+
4285+
#[cfg(test)]
4286+
mod ref_certificate_tests {
4287+
use super::{Db, RefCertificate, RepoRecord};
4288+
use chrono::Utc;
4289+
use sqlx::PgPool;
4290+
4291+
async fn db(pool: PgPool) -> Db {
4292+
let db = Db::for_testing(pool);
4293+
db.run_migrations().await.unwrap();
4294+
db
4295+
}
4296+
4297+
fn make_cert(
4298+
id: &str,
4299+
repo_id: &str,
4300+
ref_name: &str,
4301+
old_sha: &str,
4302+
new_sha: &str,
4303+
issued_at: &str,
4304+
) -> RefCertificate {
4305+
RefCertificate {
4306+
id: id.to_string(),
4307+
repo_id: repo_id.to_string(),
4308+
ref_name: ref_name.to_string(),
4309+
old_sha: old_sha.to_string(),
4310+
new_sha: new_sha.to_string(),
4311+
pusher_did: "did:key:zPUSHER".to_string(),
4312+
node_did: "did:key:zNODE".to_string(),
4313+
signature: "sig".to_string(),
4314+
issued_at: issued_at.to_string(),
4315+
}
4316+
}
4317+
4318+
#[sqlx::test]
4319+
async fn list_ref_certificates_respects_limit(pool: PgPool) {
4320+
let db = db(pool).await;
4321+
let repo_id = uuid::Uuid::new_v4().to_string();
4322+
4323+
// Create a repo to satisfy FK
4324+
db.create_repo(&RepoRecord {
4325+
id: repo_id.clone(),
4326+
name: "limit-test".into(),
4327+
owner_did: "did:key:zOWNER".into(),
4328+
description: None,
4329+
is_public: true,
4330+
default_branch: "main".into(),
4331+
created_at: Utc::now(),
4332+
updated_at: Utc::now(),
4333+
disk_path: "/tmp/limit-test".into(),
4334+
forked_from: None,
4335+
machine_id: None,
4336+
})
4337+
.await
4338+
.unwrap();
4339+
4340+
// Insert 5 certs with descending issued_at
4341+
for i in 0..5 {
4342+
db.insert_ref_certificate(&make_cert(
4343+
&format!("cert-{i}"),
4344+
&repo_id,
4345+
&format!("refs/heads/feature-{i}"),
4346+
"0000",
4347+
"1111",
4348+
&format!("2026-07-03T20:0{i}:00Z"),
4349+
))
4350+
.await
4351+
.unwrap();
4352+
}
4353+
4354+
// limit=2 returns only 2
4355+
let certs = db.list_ref_certificates(&repo_id, 2).await.unwrap();
4356+
assert_eq!(certs.len(), 2, "LIMIT 2 must return exactly 2 certs");
4357+
assert_eq!(certs[0].id, "cert-4", "most recent first");
4358+
assert_eq!(certs[1].id, "cert-3", "second most recent");
4359+
4360+
// limit=10 returns all 5 (no padding)
4361+
let all = db.list_ref_certificates(&repo_id, 10).await.unwrap();
4362+
assert_eq!(all.len(), 5, "LIMIT >= row count returns all rows");
4363+
}
4364+
4365+
#[sqlx::test]
4366+
async fn insert_ref_certificate_upserts_on_repo_ref(pool: PgPool) {
4367+
let db = db(pool).await;
4368+
let repo_id = uuid::Uuid::new_v4().to_string();
4369+
4370+
db.create_repo(&RepoRecord {
4371+
id: repo_id.clone(),
4372+
name: "upsert-test".into(),
4373+
owner_did: "did:key:zOWNER".into(),
4374+
description: None,
4375+
is_public: true,
4376+
default_branch: "main".into(),
4377+
created_at: Utc::now(),
4378+
updated_at: Utc::now(),
4379+
disk_path: "/tmp/upsert-test".into(),
4380+
forked_from: None,
4381+
machine_id: None,
4382+
})
4383+
.await
4384+
.unwrap();
4385+
4386+
// First insert
4387+
db.insert_ref_certificate(&make_cert(
4388+
"cert-original",
4389+
&repo_id,
4390+
"refs/heads/main",
4391+
"0000",
4392+
"1111",
4393+
"2026-07-03T20:00:00Z",
4394+
))
4395+
.await
4396+
.unwrap();
4397+
4398+
// Upsert same ref with new values
4399+
db.insert_ref_certificate(&make_cert(
4400+
"cert-upserted",
4401+
&repo_id,
4402+
"refs/heads/main",
4403+
"aaaa",
4404+
"bbbb",
4405+
"2026-07-03T21:00:00Z",
4406+
))
4407+
.await
4408+
.unwrap();
4409+
4410+
// Only one row exists for this ref
4411+
let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap();
4412+
assert_eq!(certs.len(), 1, "upsert must not create a duplicate row");
4413+
assert_eq!(
4414+
certs[0].id, "cert-upserted",
4415+
"upsert must replace the original cert"
4416+
);
4417+
assert_eq!(certs[0].old_sha, "aaaa", "old_sha updated");
4418+
assert_eq!(certs[0].new_sha, "bbbb", "new_sha updated");
4419+
}
4420+
4421+
#[sqlx::test]
4422+
async fn list_ref_certificates_empty_repo_returns_empty(pool: PgPool) {
4423+
let db = db(pool).await;
4424+
let certs = db
4425+
.list_ref_certificates("nonexistent-repo-id", 10)
4426+
.await
4427+
.unwrap();
4428+
assert!(certs.is_empty());
4429+
}
4430+
}

0 commit comments

Comments
 (0)