Files
ngit-grasp/src/purgatory/helpers.rs
2026-01-08 00:50:54 +00:00

699 lines
24 KiB
Rust

//! Helper functions for purgatory state event processing.
//!
//! These functions handle the late-binding extraction and matching of git refs
//! from state events. Refs are extracted at git push time rather than event
//! arrival time to enable flexible matching logic.
//!
//! ## Key Functions
//!
//! - [`can_satisfy_state`]: Used for **push authorization** - checks if a push
//! would transform the current refs into the declared state. This validates
//! that the pushed refspecs match what the state event declares.
//!
//! - [`can_apply_state`]: Used for **purgatory processing** - checks if we have
//! all the git OIDs needed to apply a state event. This validates that the
//! git data is available locally, regardless of current ref state.
use super::{RefPair, RefUpdate};
use nostr_sdk::prelude::*;
use std::collections::HashMap;
use std::path::Path;
use crate::git::oid_exists;
/// Extract ref pairs from a state event (kind 30618).
///
/// Parses all `refs/heads/*` and `refs/tags/*` tags from the event,
/// creating RefPair instances with the full ref name and target object SHA.
///
/// # Arguments
/// * `event` - The state event to extract refs from
///
/// # Returns
/// Vector of RefPair instances, one for each ref tag found
///
/// # Tag Format
/// State events use custom tags where the tag kind is the ref name:
/// - Tag kind: "refs/heads/main" or "refs/tags/v1.0"
/// - First value: commit SHA or annotated tag SHA
///
/// # Example
/// ```ignore
/// // Event with tags:
/// // ["refs/heads/main", "abc123..."]
/// // ["refs/tags/v1.0", "def456..."]
/// let refs = extract_refs_from_state(&event);
/// // Returns: [
/// // RefPair { ref_name: "refs/heads/main", object_sha: "abc123..." },
/// // RefPair { ref_name: "refs/tags/v1.0", object_sha: "def456..." }
/// // ]
/// ```
pub fn extract_refs_from_state(event: &Event) -> Vec<RefPair> {
event
.tags
.iter()
.filter_map(|tag| {
// Check if this is a custom tag with a ref name
if let TagKind::Custom(ref_name) = tag.kind() {
let ref_str = ref_name.as_ref();
// Only process refs/heads/* and refs/tags/*
if ref_str.starts_with("refs/heads/") || ref_str.starts_with("refs/tags/") {
// Get the object SHA (first value in tag)
let parts = tag.clone().to_vec();
if parts.len() >= 2 {
return Some(RefPair {
ref_name: ref_str.to_string(),
object_sha: parts[1].clone(),
});
}
}
}
None
})
.collect()
}
/// Check if a state event can be applied given the available git data.
///
/// This is used for **purgatory processing** to determine if we have all the
/// git objects needed to apply a state event. Unlike `can_satisfy_state` which
/// validates push authorization, this function only checks OID availability.
///
/// Returns true if all OIDs referenced in the state event exist in the repository.
/// Symbolic refs (starting with "ref: ") are skipped as they don't require OID lookup.
///
/// # Arguments
/// * `event` - The state event to check
/// * `repo_path` - Path to the git repository to check OIDs against
///
/// # Returns
/// true if all required OIDs exist in the repository, false otherwise
///
/// # Example
/// ```ignore
/// // State event declares:
/// // refs/heads/main -> abc123
/// // refs/heads/dev -> def456
/// // refs/heads/symlink -> ref: refs/heads/main (symbolic)
/// //
/// // If abc123 and def456 exist in repo: returns true
/// // If abc123 exists but def456 doesn't: returns false
/// // The symbolic ref doesn't require an OID check
/// ```
pub fn can_apply_state(event: &Event, repo_path: &Path) -> bool {
let state_refs = extract_refs_from_state(event);
for ref_pair in state_refs {
// Skip symbolic refs (they don't require OID lookup)
if ref_pair.object_sha.starts_with("ref: ") {
continue;
}
// Check if the OID exists in the repository
if !oid_exists(repo_path, &ref_pair.object_sha) {
return false;
}
}
true
}
/// Check if a state event can be satisfied by ref updates plus local refs.
///
/// This is used for **push authorization** to validate that a push would
/// transform the current refs into the declared state.
///
/// Returns true if applying the ref updates to local state results in exactly
/// the state declared in the event. This means:
/// 1. Filter local_refs to only branches (refs/heads/*) and tags (refs/tags/*)
/// 2. Apply pushed_updates to create a "would-be" state
/// 3. Compare would-be state with event's declared state - must match exactly
///
/// This implements correct authorization: the push must transform local state
/// into the declared state, accounting for additions, deletions, and modifications.
///
/// # Arguments
/// * `event` - The state event to check
/// * `pushed_updates` - Ref updates in the current push operation
/// * `local_refs` - Refs already existing locally (ref_name -> SHA)
///
/// # Returns
/// true if push transforms local state into declared state, false otherwise
///
/// # Example
/// ```ignore
/// // State event declares: refs/heads/main@abc123
/// // Local: refs/heads/main@old123, refs/heads/dev@def456
/// // Push updates: main old123->abc123, dev def456->0000 (delete)
/// // Result: false (event doesn't declare dev deletion)
/// ```
pub fn can_satisfy_state(
event: &Event,
pushed_updates: &[RefUpdate],
local_refs: &HashMap<String, String>,
) -> bool {
let state_refs = extract_refs_from_state(event);
// Filter local_refs to only branches and tags
let mut would_be_state: HashMap<String, String> = local_refs
.iter()
.filter(|(ref_name, _)| {
ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/")
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Apply all pushed updates to create the would-be state
for update in pushed_updates {
// Only process branches and tags
if !update.ref_name.starts_with("refs/heads/") && !update.ref_name.starts_with("refs/tags/")
{
continue;
}
if update.is_deletion() {
// Remove from would-be state
would_be_state.remove(&update.ref_name);
} else {
// Create or modify in would-be state
would_be_state.insert(update.ref_name.clone(), update.new_oid.clone());
}
}
// Convert event's state refs to a HashMap for comparison
let declared_state: HashMap<String, String> = state_refs
.into_iter()
.map(|r| (r.ref_name, r.object_sha))
.collect();
// would_be_state must exactly match declared_state
would_be_state == declared_state
}
/// Get refs from state event that aren't in pushed_refs.
///
/// Returns refs that need to be present but aren't being pushed.
/// These refs should exist in local_refs for the state to be satisfiable.
/// Useful for error messages showing what's missing.
///
/// # Arguments
/// * `event` - The state event to check
/// * `pushed_refs` - Refs being pushed in the current operation
///
/// # Returns
/// Vector of RefPair instances for refs not in pushed_refs
///
/// # Example
/// ```ignore
/// // State event declares: refs/heads/main@abc123, refs/heads/dev@def456
/// // Pushed: refs/heads/main@abc123
/// // Result: [RefPair { ref_name: "refs/heads/dev", object_sha: "def456" }]
/// ```
pub fn get_unpushed_refs(event: &Event, pushed_refs: &[RefPair]) -> Vec<RefPair> {
let state_refs = extract_refs_from_state(event);
state_refs
.into_iter()
.filter(|state_ref| {
// Include if NOT in pushed_refs (by name and SHA)
!pushed_refs.iter().any(|pushed_ref| {
pushed_ref.ref_name == state_ref.ref_name
&& pushed_ref.object_sha == state_ref.object_sha
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::{EventBuilder, Keys, Tag};
fn create_test_state_event(identifier: &str, refs: Vec<(&str, &str)>) -> Event {
let keys = Keys::generate();
let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])];
for (ref_name, sha) in refs {
tags.push(Tag::custom(
TagKind::custom(ref_name),
vec![sha.to_string()],
));
}
EventBuilder::new(Kind::from(30618), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap()
}
#[test]
fn test_extract_refs_from_state() {
let event = create_test_state_event(
"test-repo",
vec![
("refs/heads/main", "abc123"),
("refs/heads/dev", "def456"),
("refs/tags/v1.0", "789xyz"),
],
);
let refs = extract_refs_from_state(&event);
assert_eq!(refs.len(), 3);
assert!(refs
.iter()
.any(|r| r.ref_name == "refs/heads/main" && r.object_sha == "abc123"));
assert!(refs
.iter()
.any(|r| r.ref_name == "refs/heads/dev" && r.object_sha == "def456"));
assert!(refs
.iter()
.any(|r| r.ref_name == "refs/tags/v1.0" && r.object_sha == "789xyz"));
}
#[test]
fn test_extract_refs_ignores_non_ref_tags() {
let keys = Keys::generate();
let tags = vec![
Tag::custom(TagKind::d(), vec!["test-repo".to_string()]),
Tag::custom(
TagKind::custom("refs/heads/main"),
vec!["abc123".to_string()],
),
Tag::custom(TagKind::custom("some-other-tag"), vec!["value".to_string()]),
];
let event = EventBuilder::new(Kind::from(30618), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let refs = extract_refs_from_state(&event);
// Should only extract the refs/heads/main tag
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].ref_name, "refs/heads/main");
}
#[test]
fn test_can_satisfy_state_all_in_pushed() {
let event = create_test_state_event(
"test-repo",
vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
);
let pushed_updates = vec![
RefUpdate {
old_oid: "0000000000000000000000000000000000000000".to_string(),
new_oid: "abc123".to_string(),
ref_name: "refs/heads/main".to_string(),
},
RefUpdate {
old_oid: "0000000000000000000000000000000000000000".to_string(),
new_oid: "def456".to_string(),
ref_name: "refs/heads/dev".to_string(),
},
];
let local_refs = HashMap::new();
assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
}
#[test]
fn test_can_satisfy_state_split_between_pushed_and_local() {
let event = create_test_state_event(
"test-repo",
vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
);
let pushed_updates = vec![RefUpdate {
old_oid: "0000000000000000000000000000000000000000".to_string(),
new_oid: "abc123".to_string(),
ref_name: "refs/heads/main".to_string(),
}];
let mut local_refs = HashMap::new();
local_refs.insert("refs/heads/dev".to_string(), "def456".to_string());
assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
}
#[test]
fn test_can_satisfy_state_missing_ref() {
let event = create_test_state_event(
"test-repo",
vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
);
let pushed_updates = vec![RefUpdate {
old_oid: "0000000000000000000000000000000000000000".to_string(),
new_oid: "abc123".to_string(),
ref_name: "refs/heads/main".to_string(),
}];
let local_refs = HashMap::new();
// dev ref is missing
assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs));
}
#[test]
fn test_can_satisfy_state_modification() {
let event = create_test_state_event(
"test-repo",
vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
);
let pushed_updates = vec![
RefUpdate {
old_oid: "old123".to_string(),
new_oid: "abc123".to_string(),
ref_name: "refs/heads/main".to_string(),
},
RefUpdate {
old_oid: "wrong-sha".to_string(),
new_oid: "def456".to_string(),
ref_name: "refs/heads/dev".to_string(),
},
];
let mut local_refs = HashMap::new();
local_refs.insert("refs/heads/main".to_string(), "old123".to_string());
local_refs.insert("refs/heads/dev".to_string(), "wrong-sha".to_string());
// Should succeed because push updates both to match event
assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
}
#[test]
fn test_can_satisfy_state_rejects_extra_refs() {
let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
let pushed_updates = vec![
RefUpdate {
old_oid: "0000000000000000000000000000000000000000".to_string(),
new_oid: "abc123".to_string(),
ref_name: "refs/heads/main".to_string(),
},
RefUpdate {
old_oid: "old456".to_string(),
new_oid: "def456".to_string(),
ref_name: "refs/heads/dev".to_string(),
},
];
let mut local_refs = HashMap::new();
local_refs.insert("refs/heads/dev".to_string(), "old456".to_string());
// Should fail because event doesn't declare dev
assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs));
}
#[test]
fn test_can_satisfy_state_filters_non_branch_tag_refs() {
let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
let pushed_updates = vec![RefUpdate {
old_oid: "0000000000000000000000000000000000000000".to_string(),
new_oid: "abc123".to_string(),
ref_name: "refs/heads/main".to_string(),
}];
let mut local_refs = HashMap::new();
// Add some non-branch/non-tag refs that should be filtered out
local_refs.insert("refs/pull/123/head".to_string(), "xyz789".to_string());
local_refs.insert("refs/some/other/thing".to_string(), "aaa111".to_string());
// Should succeed - non-branch/tag refs are filtered out
assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
}
#[test]
fn test_can_satisfy_state_empty_event() {
let event = create_test_state_event("test-repo", vec![]);
let pushed_refs = vec![];
let local_refs = HashMap::new();
// Empty state event is satisfied
assert!(can_satisfy_state(&event, &pushed_refs, &local_refs));
}
#[test]
fn test_get_unpushed_refs() {
let event = create_test_state_event(
"test-repo",
vec![
("refs/heads/main", "abc123"),
("refs/heads/dev", "def456"),
("refs/tags/v1.0", "789xyz"),
],
);
let pushed_refs = vec![RefPair {
ref_name: "refs/heads/main".to_string(),
object_sha: "abc123".to_string(),
}];
let unpushed = get_unpushed_refs(&event, &pushed_refs);
assert_eq!(unpushed.len(), 2);
assert!(unpushed.iter().any(|r| r.ref_name == "refs/heads/dev"));
assert!(unpushed.iter().any(|r| r.ref_name == "refs/tags/v1.0"));
}
#[test]
fn test_get_unpushed_refs_all_pushed() {
let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
let pushed_refs = vec![RefPair {
ref_name: "refs/heads/main".to_string(),
object_sha: "abc123".to_string(),
}];
let unpushed = get_unpushed_refs(&event, &pushed_refs);
assert_eq!(unpushed.len(), 0);
}
#[test]
fn test_get_unpushed_refs_sha_mismatch() {
let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
let pushed_refs = vec![RefPair {
ref_name: "refs/heads/main".to_string(),
object_sha: "different-sha".to_string(), // Different SHA
}];
let unpushed = get_unpushed_refs(&event, &pushed_refs);
// Should still be unpushed because SHA doesn't match
assert_eq!(unpushed.len(), 1);
assert_eq!(unpushed[0].ref_name, "refs/heads/main");
assert_eq!(unpushed[0].object_sha, "abc123");
}
// =========================================================================
// can_apply_state tests
// =========================================================================
/// Helper to create a temporary bare git repository with a commit.
/// Returns (temp_dir, commit_hash) where commit_hash is Some if a commit was created.
fn create_test_repo_with_commit() -> (tempfile::TempDir, Option<String>) {
use std::process::Command;
let temp_dir = tempfile::tempdir().unwrap();
let bare_path = temp_dir.path();
// Initialize bare repo
Command::new("git")
.args(["init", "--bare"])
.current_dir(bare_path)
.output()
.expect("Failed to init bare git repo");
// Create a working repo to generate a commit
let work_dir = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(work_dir.path())
.output()
.expect("Failed to init work repo");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(work_dir.path())
.output()
.expect("Failed to set email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(work_dir.path())
.output()
.expect("Failed to set name");
// Disable GPG signing for tests (prevents yubikey prompts)
Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.current_dir(work_dir.path())
.output()
.expect("Failed to disable commit.gpgsign");
Command::new("git")
.args(["config", "tag.gpgsign", "false"])
.current_dir(work_dir.path())
.output()
.expect("Failed to disable tag.gpgsign");
// Create a commit
std::fs::write(work_dir.path().join("file.txt"), "content").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(work_dir.path())
.output()
.expect("Failed to add");
Command::new("git")
.args(["commit", "-m", "test"])
.current_dir(work_dir.path())
.output()
.expect("Failed to commit");
// Get the commit hash from the working repo
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(work_dir.path())
.output()
.expect("Failed to get commit hash");
let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Push to bare repo
Command::new("git")
.args(["push", bare_path.to_str().unwrap(), "HEAD:refs/heads/main"])
.current_dir(work_dir.path())
.output()
.expect("Failed to push");
(temp_dir, Some(commit_hash))
}
/// Helper to create an empty bare git repository (no commits).
fn create_empty_test_repo() -> tempfile::TempDir {
use std::process::Command;
let temp_dir = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "--bare"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to init bare git repo");
temp_dir
}
#[test]
fn test_can_apply_state_with_existing_oid() {
// Create a repo with a real commit
let (temp_repo, commit_hash) = create_test_repo_with_commit();
let repo_path = temp_repo.path();
let commit_hash = commit_hash.expect("Should have a commit");
// Create a state event referencing that commit
let event = create_test_state_event("test-repo", vec![("refs/heads/main", &commit_hash)]);
// Should return true since the OID exists
assert!(can_apply_state(&event, repo_path));
}
#[test]
fn test_can_apply_state_with_missing_oid() {
// Create an empty repo
let temp_repo = create_empty_test_repo();
let repo_path = temp_repo.path();
// Create a state event referencing a non-existent commit
let event = create_test_state_event(
"test-repo",
vec![(
"refs/heads/main",
"0000000000000000000000000000000000000000",
)],
);
// Should return false since the OID doesn't exist
assert!(!can_apply_state(&event, repo_path));
}
#[test]
fn test_can_apply_state_with_symbolic_ref() {
// Create an empty repo (no commits needed for symbolic refs)
let temp_repo = create_empty_test_repo();
let repo_path = temp_repo.path();
// Create a state event with only a symbolic ref
let event = create_test_state_event(
"test-repo",
vec![("refs/heads/main", "ref: refs/heads/other")],
);
// Should return true - symbolic refs don't require OID lookup
assert!(can_apply_state(&event, repo_path));
}
#[test]
fn test_can_apply_state_mixed_existing_and_missing() {
// Create a repo with a real commit
let (temp_repo, commit_hash) = create_test_repo_with_commit();
let repo_path = temp_repo.path();
let commit_hash = commit_hash.expect("Should have a commit");
// Create a state event with one existing and one missing OID
let event = create_test_state_event(
"test-repo",
vec![
("refs/heads/main", &commit_hash), // exists
("refs/heads/dev", "0000000000000000000000000000000000000000"), // doesn't exist
],
);
// Should return false since one OID is missing
assert!(!can_apply_state(&event, repo_path));
}
#[test]
fn test_can_apply_state_empty_event() {
let temp_repo = create_empty_test_repo();
let repo_path = temp_repo.path();
// Empty state event (no refs declared)
let event = create_test_state_event("test-repo", vec![]);
// Should return true - nothing to check
assert!(can_apply_state(&event, repo_path));
}
#[test]
fn test_can_apply_state_mixed_symbolic_and_real() {
// Create a repo with a real commit
let (temp_repo, commit_hash) = create_test_repo_with_commit();
let repo_path = temp_repo.path();
let commit_hash = commit_hash.expect("Should have a commit");
// Create a state event with both a real OID and a symbolic ref
let event = create_test_state_event(
"test-repo",
vec![
("refs/heads/main", &commit_hash), // real OID that exists
("refs/heads/alias", "ref: refs/heads/main"), // symbolic ref
],
);
// Should return true - real OID exists, symbolic ref skipped
assert!(can_apply_state(&event, repo_path));
}
}