Skip to content

Commit 89e10d0

Browse files
committed
fix(repo_store): use SHA-256 for stable advisory-lock key (#210)
DefaultHasher is not frozen by the Rust standard and has shifted across toolchain versions, so the same (owner_slug, repo_name) produced a different i64 advisory-lock key on different builds and broke cross-machine write-exclusion for the same repo. Hash with SHA-256 instead and pin the result with a golden-value test, plus a one-axis-at-a-time differential test so a regression that drops either parameter is caught. Document the SHA-256 re-keying caveat on acquire_write and in the RUN-A-NODE.md operator checklist (drain writes or cut over single-node during a rolling upgrade).
1 parent fdf716d commit 89e10d0

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

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

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,34 @@ impl RepoStore {
152152
}
153153

154154
/// Take a write lock (Postgres advisory lock), ensure repo is local, return guard.
155-
/// The lock prevents concurrent writes to the same repo across machines.
155+
///
156+
/// # Cross-machine guarantee
157+
///
158+
/// When multiple nodes share a single Postgres database (the shared-Postgres
159+
/// deployment model), this lock prevents concurrent writes to the same repo
160+
/// across machines. The lock has no effect across separate Postgres instances
161+
/// (federated per-node-DB topology).
162+
///
163+
/// # Rolling upgrade caveat (SHA-256 re-keying)
164+
///
165+
/// This function hashes `(owner_slug, repo_name)` with SHA-256 to produce a
166+
/// stable `i64` key. Earlier builds used `std::collections::DefaultHasher`,
167+
/// whose algorithm is not frozen by the Rust standard and has already
168+
/// shifted across toolchain versions. The SHA-256 swap re-keys *every*
169+
/// repo: an old-binary node holding the legacy `DefaultHasher` key and a
170+
/// new-binary node holding the SHA-256 key for the same repo compute
171+
/// *different* i64 keys, so PostgreSQL treats them as independent locks and
172+
/// the cross-machine write-exclusion this lock provides is lost for the
173+
/// duration of a rolling upgrade.
174+
///
175+
/// The accepted remediation (see issue #210) is operational: during a
176+
/// shared-Postgres rolling upgrade, **drain in-flight writes or cut over
177+
/// through a single node** (e.g. stop receive-pack / issue / pull / archive
178+
/// writers on the old version) before bringing new-binary nodes online. The
179+
/// window is bounded by the operator's rollout cadence. A future
180+
/// transition release could acquire both legacy and new keys for one cycle
181+
/// and drop the legacy one a release later; that's optional given the
182+
/// accepted-window path.
156183
pub async fn acquire_write(&self, owner_did: &str, repo_name: &str) -> Result<RepoWriteGuard> {
157184
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
158185
let lock_key = advisory_lock_key(&owner_slug, repo_name);
@@ -518,12 +545,19 @@ impl RepoWriteGuard {
518545
}
519546

520547
/// Compute a stable i64 hash for a Postgres advisory lock key.
548+
///
549+
/// Uses SHA-256 (not `DefaultHasher`) so the same `(owner_slug, repo_name)`
550+
/// produces the same `i64` key across every Rust toolchain version, operating
551+
/// system, and machine — the algorithm is frozen by the SHA-2 standard rather
552+
/// than by a std-internal implementation detail.
521553
fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
522-
use std::hash::{Hash, Hasher};
523-
let mut hasher = std::collections::hash_map::DefaultHasher::new();
524-
owner_slug.hash(&mut hasher);
525-
repo_name.hash(&mut hasher);
526-
hasher.finish() as i64
554+
use sha2::Digest;
555+
let mut hasher = sha2::Sha256::new();
556+
hasher.update(owner_slug.as_bytes());
557+
hasher.update(b":");
558+
hasher.update(repo_name.as_bytes());
559+
let digest = hasher.finalize();
560+
i64::from_le_bytes(digest[..8].try_into().expect("sha256 output is >= 8 bytes"))
527561
}
528562

529563
#[cfg(test)]
@@ -932,4 +966,46 @@ mod tests {
932966
);
933967
}
934968
}
969+
970+
// ── advisory_lock_key stability ─────────────────────────────────────────
971+
972+
#[test]
973+
fn advisory_lock_key_is_stable() {
974+
// Golden value: SHA-256("did_key_...:<repo_name>")[..8] as i64 little-endian.
975+
// If this test fails, the hashing algorithm has changed — the new key
976+
// must be backward-compatible or the rollout planned accordingly.
977+
let key = advisory_lock_key(
978+
"did_key_z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
979+
"hello",
980+
);
981+
assert_eq!(key, -6680856138670956537_i64);
982+
}
983+
984+
#[test]
985+
fn advisory_lock_key_differs_for_different_inputs() {
986+
// Vary one axis at a time so a regression that drops either parameter
987+
// from the hash is caught, not just one that drops both. The golden
988+
// test above backstops a total algorithm swap.
989+
let base = advisory_lock_key("owner_a", "repo_a");
990+
991+
// Same owner, different repo: a regression that hashes only owner_slug
992+
// would make these collide.
993+
let same_owner_diff_repo = advisory_lock_key("owner_a", "repo_b");
994+
assert_ne!(
995+
base, same_owner_diff_repo,
996+
"key must depend on repo_name, not just owner_slug"
997+
);
998+
999+
// Same repo, different owner: a regression that hashes only repo_name
1000+
// would make these collide.
1001+
let diff_owner_same_repo = advisory_lock_key("owner_b", "repo_a");
1002+
assert_ne!(
1003+
base, diff_owner_same_repo,
1004+
"key must depend on owner_slug, not just repo_name"
1005+
);
1006+
1007+
// Sanity: both axes varying at once still differs (the original shape).
1008+
let both_differ = advisory_lock_key("owner_b", "repo_b");
1009+
assert_ne!(base, both_differ);
1010+
}
9351011
}

‎docs/RUN-A-NODE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ GITLAWB_ENFORCE_OWNER_PUSH=true
178178
| Operator key | Dedicated wallet, small ETH balance, not your main treasury |
179179
| Monitoring | Watch `lastHeartbeat` on-chain; alert if > 22h since last beat |
180180
| Public URL | Must resolve and serve `/health` — peers will ping it |
181+
| Rolling upgrade (advisory-lock algorithm) | The SHA-256 advisory-lock key swap (replacing `DefaultHasher`) re-keys every repo in the first release that carries it. During a shared-Postgres rolling upgrade, old nodes compute a different `i64` key than new nodes for the same repo, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost. **Drain in-flight writes** or cut over through a **single node** before bringing new-binary nodes online. |
181182

182183
---
183184

0 commit comments

Comments
 (0)