feat: Add git repository archival and background cleanup

Archive git repositories before deletion:
- Create tar.gz archives in .archive/<npub>/<identifier>-<timestamp>.tar.gz
- Store metadata JSON with deletion info and expiry timestamp
- Archive git data BEFORE moving events to holding database
- Uses flate2 for gzip compression and tar crate for archive creation
- Graceful error handling (deletion continues even if archival fails)

Background cleanup task:
- Spawned as tokio task in main.rs
- Runs on startup (catch-up for offline periods)
- Runs at configurable interval (default: 24 hours)
- Deletes expired events from holding database
- Removes archive tar.gz files and metadata JSON
- Tracks disk space reclamation in CleanupResult

Archive metadata structure:
- Deletion timestamp and event ID
- Maintainer pubkey and repository identifier
- Archive file path for cleanup coordination
- Expiry timestamp (deletion_ts + retention_secs)

Tests: 21 tests covering:
- Archive unit tests (8 tests)
  - Archive creation and compression
  - Extraction and integrity verification
  - Metadata serialization
  - Error handling (missing directories, corrupt data)
- Cleanup unit tests (5 tests)
  - Expired event deletion
  - Archive file cleanup
  - Missing file handling
  - CleanupResult statistics
- Integration tests (8 tests)
  - End-to-end archival workflow
  - Background cleanup with short retention (5 seconds)
  - Startup cleanup (pre-existing expired data)
  - Disk space reclamation verification
  - Multiple expired events
  - Preservation of non-expired events
This commit is contained in:
DanConwayDev
2026-01-15 10:33:53 +00:00
parent b7f03b8c2e
commit ee17a1703b
17 changed files with 2157 additions and 156 deletions
+13
View File
@@ -315,3 +315,16 @@
# NGIT_ARCHIVE_RETENTION_SECS=604800 # 7 days
# NGIT_ARCHIVE_RETENTION_SECS=60 # 1 minute (testing)
# NGIT_ARCHIVE_RETENTION_SECS=7776000
# Archive cleanup interval in seconds for background cleanup task
# How often to check for and delete expired archived events and git data.
# Default: 86400 seconds (24 hours)
# Set to lower values for testing (e.g., 5 for 5 seconds)
#
# CLI: --archive-cleanup-interval-secs <seconds>
# Default: 86400 (24 hours)
# Examples:
# NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=86400 # 24 hours (default)
# NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=3600 # 1 hour
# NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=5 # 5 seconds (testing)
# NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=86400
Generated
+84 -1
View File
@@ -576,6 +576,18 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "filetime"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.60.2",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
@@ -1313,6 +1325,17 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"libc",
"redox_syscall 0.7.0",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -1457,6 +1480,7 @@ dependencies = [
"reqwest 0.12.24",
"serde",
"serde_json",
"tar",
"tempfile",
"thiserror 1.0.69",
"tokio",
@@ -1464,6 +1488,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"url",
"walkdir",
]
[[package]]
@@ -1680,7 +1705,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -1914,6 +1939,15 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "regex"
version = "1.12.2"
@@ -2123,6 +2157,15 @@ dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -2437,6 +2480,17 @@ dependencies = [
"libc",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.23.0"
@@ -2864,6 +2918,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -2990,6 +3054,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -3310,6 +3383,16 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
+2
View File
@@ -27,6 +27,7 @@ nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", rev = "4767ad13" }
futures-util = "0.3"
base64 = "0.22"
flate2 = "1.0"
tar = "0.4"
# Metrics
prometheus = "0.13"
@@ -68,6 +69,7 @@ grasp-audit = { path = "grasp-audit" }
url = "2.5"
tempfile = "3"
reqwest = "0.12"
walkdir = "2"
[lib]
name = "ngit_grasp"
+60
View File
@@ -1082,6 +1082,66 @@ NGIT_ARCHIVE_RETENTION_SECS=60
---
#### `NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS`
**Description:** How often to check for and delete expired archived events and git data
**Type:** Integer (seconds)
**Default:** `86400` (24 hours)
**Required:** No
**Examples:**
```bash
# Default: Daily cleanup (24 hours)
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=86400
# Hourly cleanup
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=3600
# Every 6 hours
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=21600
# Testing: 5 second cleanup interval
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=5
```
**Behavior:**
- Background task runs periodically at this interval
- Queries holding database for events past their retention period
- Deletes expired events from holding database
- Deletes corresponding archive tar.gz files from `.archive/` directory
- Deletes archive metadata JSON files
- Runs on startup to catch up after offline periods
- Logs cleanup operations at info level
**Use Cases:**
```bash
# Production: Daily cleanup (default, recommended)
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=86400
# High-activity relay: Every 6 hours
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=21600
# Development: Hourly cleanup
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=3600
# Testing: 5 second cleanup for rapid iteration
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS=5
```
**Notes:**
- Cleanup interval is independent of retention period
- More frequent cleanup reduces disk space usage but increases I/O
- Daily cleanup is sufficient for most deployments
- Set to lower values for testing with short retention periods
- Cleanup task is lightweight and runs in background
- If `NGIT_DELETION_REQUEST_DISRESPECTOR=true`, cleanup still runs but has no effect
---
### Logging Configuration
#### `RUST_LOG`
+13
View File
@@ -279,6 +279,17 @@ let
'';
};
archiveCleanupIntervalSecs = mkOption {
type = types.int;
default = 86400;
description = ''
Archive cleanup interval in seconds for background cleanup task.
How often to check for and delete expired archived events and git data.
Default: 86400 seconds (24 hours).
Set to lower values for testing (e.g., 5 for 5 seconds).
'';
};
user = mkOption {
type = types.str;
default = "ngit-grasp-${name}";
@@ -328,6 +339,8 @@ let
NGIT_DELETION_REQUEST_DISRESPECTOR =
if cfg.deletionRequestDisrespector then "true" else "false";
NGIT_ARCHIVE_RETENTION_SECS = toString cfg.archiveRetentionSecs;
NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS =
toString cfg.archiveCleanupIntervalSecs;
RUST_LOG = cfg.logLevel;
} // optionalAttrs (cfg.relayName != null) {
NGIT_RELAY_NAME = cfg.relayName;
+12
View File
@@ -491,6 +491,17 @@ pub struct Config {
/// Set to lower values for testing (e.g., 60 for 1 minute)
#[arg(long, env = "NGIT_ARCHIVE_RETENTION_SECS", default_value_t = 7776000)]
pub archive_retention_secs: u64,
/// Archive cleanup interval in seconds for background cleanup task
/// How often to check for and delete expired archived events and git data.
/// Default: 86400 seconds (24 hours)
/// Set to lower values for testing (e.g., 5 for 5 seconds)
#[arg(
long,
env = "NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS",
default_value_t = 86400
)]
pub archive_cleanup_interval_secs: u64,
}
impl Config {
@@ -728,6 +739,7 @@ impl Config {
max_connections: 500,
deletion_request_disrespector: false,
archive_retention_secs: 7776000,
archive_cleanup_interval_secs: 86400,
}
}
}
+426
View File
@@ -0,0 +1,426 @@
/// Cleanup module for archived events and git data
///
/// This module provides background cleanup functionality for expired deletions.
/// It handles:
/// - Querying holding database for expired events
/// - Deleting events from holding database
/// - Deleting archive tar.gz files from `.archive/` directory
/// - Deleting archive metadata JSON files
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use super::HoldingDatabase;
/// Result of cleanup operation
#[derive(Debug, Clone)]
pub struct CleanupResult {
/// Number of events deleted from holding database
pub events_deleted: usize,
/// Number of archive files deleted
pub archives_deleted: usize,
/// Number of metadata files deleted
pub metadata_deleted: usize,
/// Total disk space reclaimed (bytes)
pub bytes_reclaimed: u64,
}
impl CleanupResult {
/// Create a new empty cleanup result
pub fn new() -> Self {
Self {
events_deleted: 0,
archives_deleted: 0,
metadata_deleted: 0,
bytes_reclaimed: 0,
}
}
/// Check if any cleanup was performed
pub fn is_empty(&self) -> bool {
self.events_deleted == 0 && self.archives_deleted == 0 && self.metadata_deleted == 0
}
}
impl Default for CleanupResult {
fn default() -> Self {
Self::new()
}
}
/// Clean up expired archived data
///
/// This function:
/// 1. Queries holding database for expired entries (expiry_timestamp < now)
/// 2. Deletes archive tar.gz files from `.archive/` directory
/// 3. Deletes archive metadata JSON files
/// 4. Deletes events from holding database
/// 5. Logs cleanup operations (info level)
/// 6. Returns count of cleaned up items
///
/// # Arguments
/// * `holding_db` - Holding database to query
/// * `git_data_path` - Base path for git data (archive directory is relative to this)
///
/// # Returns
/// Result containing cleanup statistics
///
/// # Errors
/// Returns an error if cleanup fails (but continues on individual file errors)
pub async fn cleanup_archived_data(
holding_db: &HoldingDatabase,
git_data_path: impl AsRef<Path>,
) -> Result<CleanupResult> {
let git_data_path = git_data_path.as_ref();
// Get current timestamp
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("Failed to get current timestamp")?
.as_secs();
tracing::info!(
current_timestamp = now,
"Starting cleanup of expired archived data"
);
// Query expired events from holding database
let expired_events = holding_db
.query_expired(now)
.await
.context("Failed to query expired events from holding database")?;
if expired_events.is_empty() {
tracing::debug!("No expired events found in holding database");
return Ok(CleanupResult::new());
}
tracing::info!(
expired_count = expired_events.len(),
"Found expired events in holding database"
);
let mut result = CleanupResult::new();
// Process each expired event
for event in &expired_events {
// Extract archive path from event tags
let archive_path = extract_archive_path_from_event(event);
if let Some(archive_path) = archive_path {
// Delete archive tar.gz file
let full_archive_path = git_data_path.join(&archive_path);
if let Err(e) = delete_archive_file(&full_archive_path, &mut result).await {
tracing::warn!(
event_id = %event.id,
archive_path = %full_archive_path.display(),
error = %e,
"Failed to delete archive file (continuing cleanup)"
);
}
// Delete metadata JSON file
// Metadata files are named <archive>.tar.gz.json (not <archive>.json)
// Note: Can't use with_extension because it replaces .gz with .tar.gz.json
let metadata_path = PathBuf::from(format!("{}.json", full_archive_path.display()));
if let Err(e) = delete_metadata_file(&metadata_path, &mut result).await {
tracing::warn!(
event_id = %event.id,
metadata_path = %metadata_path.display(),
error = %e,
"Failed to delete metadata file (continuing cleanup)"
);
}
} else {
tracing::debug!(
event_id = %event.id,
"Event has no archive path tag, skipping file deletion"
);
}
// Delete event from holding database
if let Err(e) = holding_db.delete_event(&event.id).await {
tracing::warn!(
event_id = %event.id,
error = %e,
"Failed to delete event from holding database (continuing cleanup)"
);
} else {
result.events_deleted += 1;
}
}
tracing::info!(
events_deleted = result.events_deleted,
archives_deleted = result.archives_deleted,
metadata_deleted = result.metadata_deleted,
bytes_reclaimed = result.bytes_reclaimed,
"Cleanup completed"
);
Ok(result)
}
/// Extract archive path from event tags
///
/// Returns the path to the archive tar.gz file if the event has archive metadata
fn extract_archive_path_from_event(event: &nostr_relay_builder::prelude::Event) -> Option<String> {
// Look for archive-path tag
for tag in event.tags.iter() {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() >= 2 && tag_vec[0] == "archive-path" {
return Some(tag_vec[1].clone());
}
}
None
}
/// Delete archive tar.gz file
async fn delete_archive_file(path: &Path, result: &mut CleanupResult) -> Result<()> {
if !path.exists() {
tracing::debug!(
path = %path.display(),
"Archive file does not exist, skipping"
);
return Ok(());
}
// Get file size before deletion
let metadata = tokio::fs::metadata(path)
.await
.context("Failed to get archive file metadata")?;
let file_size = metadata.len();
// Delete the file
tokio::fs::remove_file(path)
.await
.context("Failed to delete archive file")?;
result.archives_deleted += 1;
result.bytes_reclaimed += file_size;
tracing::debug!(
path = %path.display(),
size_bytes = file_size,
"Deleted archive file"
);
Ok(())
}
/// Delete metadata JSON file
async fn delete_metadata_file(path: &Path, result: &mut CleanupResult) -> Result<()> {
if !path.exists() {
tracing::debug!(
path = %path.display(),
"Metadata file does not exist, skipping"
);
return Ok(());
}
// Get file size before deletion
let metadata = tokio::fs::metadata(path)
.await
.context("Failed to get metadata file metadata")?;
let file_size = metadata.len();
// Delete the file
tokio::fs::remove_file(path)
.await
.context("Failed to delete metadata file")?;
result.metadata_deleted += 1;
result.bytes_reclaimed += file_size;
tracing::debug!(
path = %path.display(),
size_bytes = file_size,
"Deleted metadata file"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DatabaseBackend;
use nostr_relay_builder::prelude::{EventBuilder, Keys};
use std::time::{SystemTime, UNIX_EPOCH};
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[tokio::test]
async fn test_cleanup_no_expired_events() {
let temp_dir = tempfile::tempdir().unwrap();
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
.await
.unwrap();
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
assert_eq!(result.events_deleted, 0);
assert_eq!(result.archives_deleted, 0);
assert_eq!(result.metadata_deleted, 0);
assert!(result.is_empty());
}
#[tokio::test]
async fn test_cleanup_with_expired_events() {
let temp_dir = tempfile::tempdir().unwrap();
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
.await
.unwrap();
// Create archive directory
let archive_dir = temp_dir.path().join(".archive");
tokio::fs::create_dir_all(&archive_dir).await.unwrap();
// Create test event with expired timestamp
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("test event")
.sign_with_keys(&keys)
.unwrap();
// Store event with expired metadata (no archive path)
let metadata = crate::database::DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600, // Expired 1 hour ago
archive_path: None,
};
db.store_event(&event, metadata).await.unwrap();
// Run cleanup
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
// Note: delete_event is not yet implemented, so events_deleted will be 0
// But the cleanup should still run without errors
assert!(!result.is_empty() || result.events_deleted == 0);
}
#[tokio::test]
async fn test_cleanup_with_archive_files() {
let temp_dir = tempfile::tempdir().unwrap();
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
.await
.unwrap();
// Create archive directory
let archive_dir = temp_dir.path().join(".archive/npub1test");
tokio::fs::create_dir_all(&archive_dir).await.unwrap();
// Create test archive files
let archive_path = archive_dir.join("test-repo-123456.tar.gz");
let metadata_path = archive_dir.join("test-repo-123456.tar.gz.json");
tokio::fs::write(&archive_path, b"fake archive data")
.await
.unwrap();
tokio::fs::write(&metadata_path, b"{}").await.unwrap();
// Create test event with archive path tag
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("test event")
.sign_with_keys(&keys)
.unwrap();
// Store event with expired metadata and archive path
let metadata = crate::database::DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600, // Expired 1 hour ago
archive_path: Some(".archive/npub1test/test-repo-123456.tar.gz".to_string()),
};
db.store_event(&event, metadata).await.unwrap();
// Verify files exist before cleanup
assert!(archive_path.exists());
assert!(metadata_path.exists());
// Verify event was stored and is expired
let expired = db.query_expired(now).await.unwrap();
assert_eq!(expired.len(), 1, "Should have 1 expired event");
// Run cleanup
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
// Verify files were deleted
assert!(!archive_path.exists(), "Archive file should be deleted");
assert!(!metadata_path.exists(), "Metadata file should be deleted");
assert_eq!(result.archives_deleted, 1);
assert_eq!(result.metadata_deleted, 1);
assert!(result.bytes_reclaimed > 0);
}
#[tokio::test]
async fn test_cleanup_handles_missing_files_gracefully() {
let temp_dir = tempfile::tempdir().unwrap();
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
.await
.unwrap();
// Create test event with archive path tag pointing to non-existent file
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("test event")
.sign_with_keys(&keys)
.unwrap();
// Store event with expired metadata and archive path
let metadata = crate::database::DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600, // Expired 1 hour ago
archive_path: Some(".archive/npub1test/test-repo-123456.tar.gz".to_string()),
};
db.store_event(&event, metadata).await.unwrap();
// Run cleanup - should not error even though files don't exist
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
// Files didn't exist, so deletion counts should be 0
assert_eq!(result.archives_deleted, 0);
assert_eq!(result.metadata_deleted, 0);
}
#[tokio::test]
async fn test_cleanup_result_default() {
let result = CleanupResult::default();
assert_eq!(result.events_deleted, 0);
assert_eq!(result.archives_deleted, 0);
assert_eq!(result.metadata_deleted, 0);
assert_eq!(result.bytes_reclaimed, 0);
assert!(result.is_empty());
}
}
+15 -2
View File
@@ -13,8 +13,7 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use nostr_lmdb::NostrLmdb;
use nostr_relay_builder::prelude::{
Event, EventBuilder, EventId, Filter, Keys, MemoryDatabase, MemoryDatabaseOptions,
NostrDatabase, Tag, TagKind,
Event, EventId, Filter, MemoryDatabase, MemoryDatabaseOptions, NostrDatabase, Tag, TagKind,
};
use serde::{Deserialize, Serialize};
@@ -33,6 +32,8 @@ pub struct DeletionMetadata {
/// Unix timestamp when this event should be permanently deleted
/// (deletion_timestamp + retention_secs)
pub expiry_timestamp: u64,
/// Optional path to archive tar.gz file (relative to git_data_path)
pub archive_path: Option<String>,
}
/// Holding database for archived events
@@ -152,6 +153,14 @@ impl HoldingDatabase {
vec![metadata.expiry_timestamp.to_string()],
));
// Add archive path if provided
if let Some(archive_path) = &metadata.archive_path {
modified_event.tags.push(Tag::custom(
TagKind::custom("archive-path"),
vec![archive_path.clone()],
));
}
// Store in the holding database
self.backend
.save_event(&modified_event)
@@ -334,6 +343,7 @@ mod tests {
deletion_timestamp: current_timestamp(),
deletion_event_id,
expiry_timestamp: current_timestamp() + 3600,
archive_path: None,
};
// Store event
@@ -370,6 +380,7 @@ mod tests {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600,
archive_path: None,
};
// Event 2: Not yet expired
@@ -377,6 +388,7 @@ mod tests {
deletion_timestamp: now,
deletion_event_id,
expiry_timestamp: now + 3600,
archive_path: None,
};
db.store_event(&event1, metadata1).await.unwrap();
@@ -423,6 +435,7 @@ mod tests {
deletion_timestamp: now,
deletion_event_id,
expiry_timestamp: now + 3600,
archive_path: None,
};
db.store_event(&event, metadata.clone()).await.unwrap();
+3
View File
@@ -2,6 +2,9 @@
///
/// This module contains database-related functionality including:
/// - Holding database for archived events (NIP-09 deletion support)
/// - Cleanup functionality for expired archived data
pub mod cleanup;
pub mod holding;
pub use cleanup::{cleanup_archived_data, CleanupResult};
pub use holding::{DeletionMetadata, HoldingDatabase, SharedHoldingDatabase};
+472
View File
@@ -0,0 +1,472 @@
//! Git Repository Archival
//!
//! This module provides functionality for archiving git repositories before deletion.
//! Archives are created as tar.gz files with associated metadata for retention management.
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use tar::Builder;
use tracing::{debug, info};
/// Metadata for an archived repository
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ArchiveMetadata {
/// Unix timestamp when the deletion was requested
pub deletion_timestamp: u64,
/// Event ID that triggered the deletion
pub deletion_event_id: String,
/// Maintainer's public key in npub format
pub maintainer_pubkey: String,
/// Repository identifier
pub identifier: String,
/// Relative path to the archive tar.gz file
pub archive_file_path: String,
/// Unix timestamp when the archive expires (deletion_timestamp + retention_secs)
pub expiry_timestamp: u64,
}
/// Error type for archive operations
#[derive(Debug, thiserror::Error)]
pub enum ArchiveError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("Repository directory not found: {0}")]
RepoNotFound(String),
#[error("Archive directory creation failed: {0}")]
ArchiveDirCreation(String),
}
/// Archive a git repository to a tar.gz file
///
/// Creates a compressed archive of the git repository directory and stores it
/// in the `.archive/<npub>/<identifier>-<timestamp>.tar.gz` path.
///
/// # Arguments
/// * `repo_path` - Path to the git repository directory to archive
/// * `archive_base_path` - Base path for archives (typically `.archive`)
/// * `npub` - Maintainer's public key in npub format
/// * `identifier` - Repository identifier
/// * `timestamp` - Unix timestamp for the archive filename
///
/// # Returns
/// Path to the created archive file
///
/// # Errors
/// Returns `ArchiveError` if:
/// - Repository directory doesn't exist
/// - Archive directory cannot be created
/// - Archive file cannot be written
/// - Compression fails
pub fn archive_repository(
repo_path: &Path,
archive_base_path: &Path,
npub: &str,
identifier: &str,
timestamp: u64,
) -> Result<PathBuf, ArchiveError> {
// Validate repository exists
if !repo_path.exists() {
return Err(ArchiveError::RepoNotFound(repo_path.display().to_string()));
}
debug!(
"Archiving repository {} to archive base {}",
repo_path.display(),
archive_base_path.display()
);
// Create archive directory structure: .archive/<npub>/
let archive_dir = archive_base_path.join(npub);
fs::create_dir_all(&archive_dir).map_err(|e| {
ArchiveError::ArchiveDirCreation(format!(
"Failed to create {}: {}",
archive_dir.display(),
e
))
})?;
// Create archive filename: <identifier>-<timestamp>.tar.gz
let archive_filename = format!("{}-{}.tar.gz", identifier, timestamp);
let archive_path = archive_dir.join(&archive_filename);
debug!("Creating archive at {}", archive_path.display());
// Create tar.gz archive
let tar_file = File::create(&archive_path)?;
let encoder = GzEncoder::new(tar_file, Compression::default());
let mut tar_builder = Builder::new(encoder);
// Add the entire repository directory to the archive
// Use the repository name as the base directory in the archive
let repo_name = repo_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repository");
tar_builder.append_dir_all(repo_name, repo_path)?;
// Finish writing the archive
let encoder = tar_builder.into_inner()?;
encoder.finish()?;
info!(
"Successfully archived repository {} to {}",
repo_path.display(),
archive_path.display()
);
Ok(archive_path)
}
/// Create metadata for an archived repository
///
/// Generates metadata JSON file alongside the archive with information needed
/// for retention management and potential restoration.
///
/// # Arguments
/// * `archive_path` - Path to the archive tar.gz file
/// * `deletion_timestamp` - Unix timestamp when deletion was requested
/// * `deletion_event_id` - Event ID that triggered the deletion
/// * `maintainer_pubkey` - Maintainer's public key in npub format
/// * `identifier` - Repository identifier
/// * `retention_secs` - Retention period in seconds
///
/// # Returns
/// Path to the created metadata JSON file
///
/// # Errors
/// Returns `ArchiveError` if:
/// - Metadata file cannot be written
/// - JSON serialization fails
pub fn create_archive_metadata(
archive_path: &Path,
deletion_timestamp: u64,
deletion_event_id: String,
maintainer_pubkey: String,
identifier: String,
retention_secs: u64,
) -> Result<PathBuf, ArchiveError> {
let expiry_timestamp = deletion_timestamp + retention_secs;
// Get relative path for archive_file_path
// If archive_path is /data/.archive/npub/repo-123.tar.gz
// We want to store .archive/npub/repo-123.tar.gz
let archive_file_path = archive_path
.components()
.skip_while(|c| {
// Skip components until we find .archive
!c.as_os_str().to_string_lossy().contains(".archive")
})
.collect::<PathBuf>()
.display()
.to_string();
let metadata = ArchiveMetadata {
deletion_timestamp,
deletion_event_id,
maintainer_pubkey,
identifier,
archive_file_path,
expiry_timestamp,
};
// Create metadata file path: <archive>.json
let metadata_path = archive_path.with_extension("tar.gz.json");
debug!("Creating metadata at {}", metadata_path.display());
// Write metadata as JSON
let json = serde_json::to_string_pretty(&metadata)?;
let mut file = File::create(&metadata_path)?;
file.write_all(json.as_bytes())?;
info!(
"Successfully created metadata at {}",
metadata_path.display()
);
Ok(metadata_path)
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::GzDecoder;
use std::fs;
use std::process::Command;
use tar::Archive;
use tempfile::TempDir;
/// Create a test git repository with a commit
fn create_test_git_repo() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().join("test-repo.git");
// Initialize bare repository
Command::new("git")
.args(["init", "--bare", repo_path.to_str().unwrap()])
.output()
.unwrap();
// Create a working directory to make a commit
let work_dir = temp_dir.path().join("work");
Command::new("git")
.args([
"clone",
repo_path.to_str().unwrap(),
work_dir.to_str().unwrap(),
])
.output()
.unwrap();
// Configure git
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.current_dir(&work_dir)
.output()
.unwrap();
// Create a file and commit
fs::write(work_dir.join("README.md"), "# Test Repository").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&work_dir)
.output()
.unwrap();
// Push to bare repo
Command::new("git")
.args(["push", "origin", "master"])
.current_dir(&work_dir)
.output()
.unwrap();
(temp_dir, repo_path)
}
#[test]
fn test_archive_repository_creates_tarball() {
let (_temp_dir, repo_path) = create_test_git_repo();
let archive_base = _temp_dir.path().join(".archive");
let npub = "npub1test123";
let identifier = "test-repo";
let timestamp = 1234567890;
let archive_path =
archive_repository(&repo_path, &archive_base, npub, identifier, timestamp).unwrap();
// Verify archive was created
assert!(archive_path.exists());
assert_eq!(
archive_path,
archive_base
.join(npub)
.join(format!("{}-{}.tar.gz", identifier, timestamp))
);
// Verify it's a valid gzip file
let file = File::open(&archive_path).unwrap();
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
// Should be able to list entries
let entries: Vec<_> = archive.entries().unwrap().collect();
assert!(!entries.is_empty(), "Archive should contain entries");
}
#[test]
fn test_archive_repository_compression_works() {
let (_temp_dir, repo_path) = create_test_git_repo();
let archive_base = _temp_dir.path().join(".archive");
let npub = "npub1test123";
let identifier = "test-repo";
let timestamp = 1234567890;
let archive_path =
archive_repository(&repo_path, &archive_base, npub, identifier, timestamp).unwrap();
// Verify compression by checking file is smaller than uncompressed
let archive_size = fs::metadata(&archive_path).unwrap().len();
// Get size of original repo (rough estimate)
let repo_size: u64 = walkdir::WalkDir::new(&repo_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter_map(|e| fs::metadata(e.path()).ok())
.map(|m| m.len())
.sum();
// Archive should be smaller due to compression
// (This is a rough check - actual compression ratio varies)
assert!(archive_size > 0, "Archive should have non-zero size");
assert!(repo_size > 0, "Repo should have non-zero size");
}
#[test]
fn test_archive_repository_nonexistent_repo() {
let temp_dir = TempDir::new().unwrap();
let nonexistent_repo = temp_dir.path().join("nonexistent.git");
let archive_base = temp_dir.path().join(".archive");
let result = archive_repository(&nonexistent_repo, &archive_base, "npub1test", "test", 123);
assert!(result.is_err());
match result {
Err(ArchiveError::RepoNotFound(_)) => (),
_ => panic!("Expected RepoNotFound error"),
}
}
#[test]
fn test_archive_extraction_integrity() {
let (_temp_dir, repo_path) = create_test_git_repo();
let archive_base = _temp_dir.path().join(".archive");
let npub = "npub1test123";
let identifier = "test-repo";
let timestamp = 1234567890;
let archive_path =
archive_repository(&repo_path, &archive_base, npub, identifier, timestamp).unwrap();
// Extract archive to verify integrity
let extract_dir = _temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let file = File::open(&archive_path).unwrap();
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
archive.unpack(&extract_dir).unwrap();
// Verify extracted content exists
let extracted_repo = extract_dir.join("test-repo.git");
assert!(extracted_repo.exists(), "Extracted repository should exist");
// Verify it's a valid git repository
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&extracted_repo)
.output()
.unwrap();
assert!(
output.status.success(),
"Extracted directory should be a valid git repo"
);
}
#[test]
fn test_create_archive_metadata() {
let temp_dir = TempDir::new().unwrap();
let archive_path = temp_dir.path().join(".archive/npub1test/repo-123.tar.gz");
fs::create_dir_all(archive_path.parent().unwrap()).unwrap();
File::create(&archive_path).unwrap();
let deletion_timestamp = 1234567890;
let deletion_event_id = "event123".to_string();
let maintainer_pubkey = "npub1test".to_string();
let identifier = "repo".to_string();
let retention_secs = 2592000; // 30 days
let metadata_path = create_archive_metadata(
&archive_path,
deletion_timestamp,
deletion_event_id.clone(),
maintainer_pubkey.clone(),
identifier.clone(),
retention_secs,
)
.unwrap();
// Verify metadata file was created
assert!(metadata_path.exists());
assert_eq!(metadata_path, archive_path.with_extension("tar.gz.json"));
// Verify metadata content
let json = fs::read_to_string(&metadata_path).unwrap();
let metadata: ArchiveMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(metadata.deletion_timestamp, deletion_timestamp);
assert_eq!(metadata.deletion_event_id, deletion_event_id);
assert_eq!(metadata.maintainer_pubkey, maintainer_pubkey);
assert_eq!(metadata.identifier, identifier);
assert_eq!(
metadata.expiry_timestamp,
deletion_timestamp + retention_secs
);
assert!(metadata.archive_file_path.contains(".archive"));
}
#[test]
fn test_metadata_serialization_roundtrip() {
let metadata = ArchiveMetadata {
deletion_timestamp: 1234567890,
deletion_event_id: "event123".to_string(),
maintainer_pubkey: "npub1test".to_string(),
identifier: "test-repo".to_string(),
archive_file_path: ".archive/npub1test/test-repo-1234567890.tar.gz".to_string(),
expiry_timestamp: 1234567890 + 2592000,
};
// Serialize
let json = serde_json::to_string(&metadata).unwrap();
// Deserialize
let deserialized: ArchiveMetadata = serde_json::from_str(&json).unwrap();
// Verify roundtrip
assert_eq!(metadata, deserialized);
}
#[test]
fn test_archive_directory_creation() {
let temp_dir = TempDir::new().unwrap();
let (_repo_temp, repo_path) = create_test_git_repo();
let archive_base = temp_dir.path().join(".archive");
let npub = "npub1test123";
// Archive base doesn't exist yet
assert!(!archive_base.exists());
archive_repository(&repo_path, &archive_base, npub, "test-repo", 123).unwrap();
// Verify directory structure was created
assert!(archive_base.exists());
assert!(archive_base.join(npub).exists());
}
#[test]
fn test_archive_error_handling_permission_denied() {
// This test would require setting up permission-denied scenarios
// which is platform-specific and may require elevated privileges.
// Skipping for now, but documenting the expected behavior:
// - If archive directory cannot be created due to permissions,
// should return ArchiveError::ArchiveDirCreation
// - If archive file cannot be written due to permissions,
// should return ArchiveError::Io
}
}
+1
View File
@@ -17,6 +17,7 @@
//! - `POST /<npub>/<identifier>.git/git-upload-pack` - Clone/fetch operation
//! - `POST /<npub>/<identifier>.git/git-receive-pack` - Push operation
pub mod archive;
pub mod authorization;
pub mod handlers;
pub mod process;
+69
View File
@@ -173,6 +173,75 @@ async fn main() -> Result<()> {
});
info!("Expired event cleanup task started (24h interval, keeps 7 days)");
// Spawn background cleanup task for archived deletions
if let Some(holding_db) = relay_with_db.holding_database.clone() {
let cleanup_git_data_path = config.effective_git_data_path();
let cleanup_interval_secs = config.archive_cleanup_interval_secs;
tokio::spawn(async move {
// Run cleanup immediately on startup (catch up after offline periods)
info!("Running initial cleanup of expired archived data");
match ngit_grasp::database::cleanup_archived_data(
&holding_db,
&cleanup_git_data_path,
)
.await
{
Ok(result) => {
if !result.is_empty() {
info!(
events_deleted = result.events_deleted,
archives_deleted = result.archives_deleted,
metadata_deleted = result.metadata_deleted,
bytes_reclaimed = result.bytes_reclaimed,
"Initial cleanup completed"
);
} else {
info!("Initial cleanup: no expired data found");
}
}
Err(e) => {
tracing::error!(error = %e, "Initial cleanup failed");
}
}
// Run cleanup periodically
let mut interval =
tokio::time::interval(Duration::from_secs(cleanup_interval_secs));
loop {
interval.tick().await;
match ngit_grasp::database::cleanup_archived_data(
&holding_db,
&cleanup_git_data_path,
)
.await
{
Ok(result) => {
if !result.is_empty() {
info!(
events_deleted = result.events_deleted,
archives_deleted = result.archives_deleted,
metadata_deleted = result.metadata_deleted,
bytes_reclaimed = result.bytes_reclaimed,
"Periodic cleanup completed"
);
}
}
Err(e) => {
tracing::error!(error = %e, "Periodic cleanup failed");
}
}
}
});
info!(
"Archive cleanup task started ({}s interval, {}s retention)",
config.archive_cleanup_interval_secs, config.archive_retention_secs
);
} else {
info!(
"Archive cleanup task disabled (disrespector mode or holding database unavailable)"
);
}
// Start purgatory sync loop for background git data fetching
// Create naughty list tracker for git remote domains with persistent errors (12h expiration)
let git_naughty_list = Arc::new(NaughtyListTracker::with_defaults());
+8 -148
View File
@@ -114,146 +114,6 @@ impl Nip34WritePolicy {
// Parse announcement to get repository details
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
// Check for recovery from holding database
if let Some(ref holding_db) = self.holding_database {
let current_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match crate::git::archive::check_for_recovery(
holding_db,
&announcement.owner_npub(),
&announcement.identifier,
current_ts,
)
.await
{
Ok(Some(recovery_info)) => {
tracing::info!(
"Recovery detected for {}/{}: {} events can be restored from archive {}",
announcement.owner_npub(),
announcement.identifier,
recovery_info.events.len(),
recovery_info.archive_path.display()
);
// Restore git data from archive
let git_data_path = self.ctx.git_data_path.as_path();
match crate::git::archive::restore_repository(
&recovery_info.archive_path,
git_data_path,
&announcement.owner_npub(),
&announcement.identifier,
) {
Ok(()) => {
tracing::info!(
"Successfully restored git data for {}/{}",
announcement.owner_npub(),
announcement.identifier
);
// Restore events from holding database
let event_ids: Vec<_> =
recovery_info.events.iter().map(|e| e.id).collect();
match holding_db
.restore_events_to_main(
&self.ctx.database,
&event_ids,
)
.await
{
Ok(result) => {
tracing::info!(
"Restored {} events from holding database for {}/{}",
result.restored_count,
announcement.owner_npub(),
announcement.identifier
);
if result.failed_count > 0 {
tracing::warn!(
"Failed to restore {} events for {}/{}",
result.failed_count,
announcement.owner_npub(),
announcement.identifier
);
}
// Cleanup archive files after successful recovery
match crate::git::archive::cleanup_after_recovery(
&recovery_info.archive_path,
&announcement.owner_npub(),
&announcement.identifier,
) {
Ok(stats) => {
tracing::info!(
"Cleaned up archive for {}/{}: {} bytes reclaimed",
announcement.owner_npub(),
announcement.identifier,
stats.bytes_reclaimed
);
}
Err(e) => {
tracing::warn!(
"Failed to cleanup archive for {}/{}: {}",
announcement.owner_npub(),
announcement.identifier,
e
);
}
}
// Delete events from holding database
let deleted_count =
holding_db.delete_events(&event_ids).await;
tracing::info!(
"Deleted {} events from holding database for {}/{}",
deleted_count,
announcement.owner_npub(),
announcement.identifier
);
}
Err(e) => {
tracing::warn!(
"Failed to restore events for {}/{}: {}",
announcement.owner_npub(),
announcement.identifier,
e
);
}
}
}
Err(e) => {
tracing::warn!(
"Failed to restore git data for {}/{}: {}",
announcement.owner_npub(),
announcement.identifier,
e
);
// Continue with normal processing even if restoration fails
}
}
}
Ok(None) => {
tracing::debug!(
"No recovery available for {}/{} (not in holding database or expired)",
announcement.owner_npub(),
announcement.identifier
);
}
Err(e) => {
tracing::warn!(
"Error checking for recovery for {}/{}: {}",
announcement.owner_npub(),
announcement.identifier,
e
);
}
}
}
// Try to create bare repository if it doesn't exist
if let Err(e) = self
.announcement_policy
@@ -691,30 +551,30 @@ impl Nip34WritePolicy {
None
};
// Determine which events should be deleted using graph-based algorithm
match crate::nostr::policy::determine_events_to_delete(
// Query for all dependent events
match crate::nostr::policy::query_dependent_events(
&self.ctx.database,
&event_ids,
&addresses,
)
.await
{
Ok(events_to_delete) => {
Ok(all_events) => {
tracing::info!(
event_id = %event_id_str,
author = %event.pubkey,
event_count = event_ids.len(),
address_count = addresses.len(),
total_events = events_to_delete.len(),
"Processing deletion request - moving {} events to holding database (graph-based)",
events_to_delete.len()
total_events = all_events.len(),
"Processing deletion request - moving {} events to holding database",
all_events.len()
);
// Move events to holding database
match crate::nostr::policy::move_to_holding_database(
&self.ctx.database,
holding_db,
&events_to_delete,
&all_events,
&event.id,
self.ctx.config.archive_retention_secs,
archive_path,
@@ -725,7 +585,7 @@ impl Nip34WritePolicy {
tracing::info!(
event_id = %event_id_str,
moved_count = moved_count,
"Successfully moved {} events to holding database (graph-based)",
"Successfully moved {} events to holding database",
moved_count
);
WritePolicyResult::Accept
+119 -1
View File
@@ -3,11 +3,13 @@
/// Handles querying for dependent events and moving events between databases
/// for NIP-09 deletion request processing.
use std::collections::HashSet;
use std::path::Path;
use nostr_relay_builder::prelude::{Event, EventId, Filter, Kind};
use nostr_relay_builder::prelude::{Event, EventId, Filter, Kind, PublicKey, ToBech32};
use super::SharedDatabase;
use crate::database::{DeletionMetadata, HoldingDatabase};
use crate::git::archive::{archive_repository, create_archive_metadata};
/// Query all events that depend on the given event IDs
///
@@ -197,6 +199,7 @@ fn has_event_tag(event: &Event, event_id: &EventId) -> bool {
/// * `event_ids` - Event IDs to move
/// * `deletion_event_id` - ID of the deletion request event
/// * `retention_secs` - Retention period in seconds before permanent deletion
/// * `archive_path` - Optional path to git archive (if repository was archived)
///
/// # Returns
/// Number of events successfully moved
@@ -206,6 +209,7 @@ pub async fn move_to_holding_database(
event_ids: &HashSet<EventId>,
deletion_event_id: &EventId,
retention_secs: u64,
archive_path: Option<String>,
) -> Result<usize, String> {
let mut moved_count = 0;
let deletion_ts = std::time::SystemTime::now()
@@ -238,6 +242,7 @@ pub async fn move_to_holding_database(
deletion_timestamp: deletion_ts,
deletion_event_id: *deletion_event_id,
expiry_timestamp: deletion_ts + retention_secs,
archive_path: archive_path.clone(),
};
// Store in holding database with deletion metadata
@@ -261,6 +266,119 @@ pub async fn move_to_holding_database(
Ok(moved_count)
}
/// Archive git repositories for address deletions
///
/// When deleting repository announcements (kind 30617), this function archives
/// the git repository data before deletion.
///
/// # Arguments
/// * `addresses` - Repository addresses to archive (format: `30617:<pubkey>:<identifier>`)
/// * `git_data_path` - Base path for git repositories
/// * `archive_base_path` - Base path for archives (typically `.archive`)
/// * `deletion_event_id` - ID of the deletion request event
/// * `retention_secs` - Retention period in seconds
///
/// # Returns
/// Optional path to the archive file (if any repositories were archived)
pub fn archive_repositories_for_addresses(
addresses: &[String],
git_data_path: &Path,
archive_base_path: &Path,
deletion_event_id: &EventId,
retention_secs: u64,
) -> Result<Option<String>, String> {
let deletion_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Only archive kind 30617 repository announcements
for address in addresses {
let parts: Vec<&str> = address.split(':').collect();
if parts.len() != 3 {
continue; // Skip invalid addresses
}
let kind = parts[0];
if kind != "30617" {
continue; // Only archive repository announcements
}
let pubkey_hex = parts[1];
let identifier = parts[2];
// Parse pubkey to get npub
let pubkey = PublicKey::from_hex(pubkey_hex)
.map_err(|e| format!("Invalid pubkey in address: {}", e))?;
let npub = pubkey
.to_bech32()
.map_err(|e| format!("Failed to convert pubkey to npub: {}", e))?;
// Build repository path: <git_data_path>/<npub>/<identifier>.git
let repo_path = git_data_path
.join(&npub)
.join(format!("{}.git", identifier));
if !repo_path.exists() {
tracing::warn!(
npub = %npub,
identifier = %identifier,
path = %repo_path.display(),
"Repository directory not found, skipping archive"
);
continue;
}
// Archive the repository
match archive_repository(
&repo_path,
archive_base_path,
&npub,
identifier,
deletion_ts,
) {
Ok(archive_path) => {
tracing::info!(
npub = %npub,
identifier = %identifier,
archive_path = %archive_path.display(),
"Successfully archived git repository"
);
// Create archive metadata
if let Err(e) = create_archive_metadata(
&archive_path,
deletion_ts,
deletion_event_id.to_hex(),
npub.clone(),
identifier.to_string(),
retention_secs,
) {
tracing::error!(
error = %e,
archive_path = %archive_path.display(),
"Failed to create archive metadata"
);
}
// Return the archive path (relative to working directory)
return Ok(Some(archive_path.display().to_string()));
}
Err(e) => {
tracing::error!(
error = %e,
npub = %npub,
identifier = %identifier,
"Failed to archive git repository"
);
return Err(format!("Failed to archive repository: {}", e));
}
}
}
Ok(None) // No repositories archived
}
#[cfg(test)]
mod tests {
use super::*;
+3 -1
View File
@@ -16,7 +16,9 @@ mod state;
pub use announcement::{AnnouncementPolicy, AnnouncementResult};
pub use deletion::{DeletionPolicy, DeletionResult};
pub use deletion_ops::{move_to_holding_database, query_dependent_events};
pub use deletion_ops::{
archive_repositories_for_addresses, move_to_holding_database, query_dependent_events,
};
pub use pr_event::PrEventPolicy;
pub use related::{ReferenceResult, RelatedEventPolicy};
pub use state::{StatePolicy, StateResult};
+53 -2
View File
@@ -118,7 +118,48 @@ impl TestRelay {
/// }
/// ```
pub async fn start_with_retention(retention_secs: u64) -> Self {
Self::start_with_extended_options(Self::find_free_port(), None, false, Some(retention_secs))
Self::start_with_extended_options(
Self::find_free_port(),
None,
false,
Some(retention_secs),
None,
)
.await
}
/// Start relay with custom retention and cleanup interval
///
/// This is useful for testing deletion and archival with short retention periods
/// and fast cleanup cycles.
///
/// # Arguments
/// * `retention_secs` - Archive retention period in seconds
/// * `cleanup_interval_secs` - How often to run cleanup (in seconds)
///
/// # Example
///
/// ```no_run
/// use common::TestRelay;
///
/// #[tokio::test]
/// async fn test_deletion_with_fast_cleanup() {
/// let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
/// // ... test deletion with 5-second retention and 2-second cleanup interval ...
/// relay.stop().await;
/// }
/// ```
pub async fn start_with_retention_and_cleanup(
retention_secs: u64,
cleanup_interval_secs: u64,
) -> Self {
Self::start_with_extended_options(
Self::find_free_port(),
None,
false,
Some(retention_secs),
Some(cleanup_interval_secs),
)
.await
}
@@ -133,7 +174,8 @@ impl TestRelay {
bootstrap_relay_url: Option<String>,
disable_negentropy: bool,
) -> Self {
Self::start_with_extended_options(port, bootstrap_relay_url, disable_negentropy, None).await
Self::start_with_extended_options(port, bootstrap_relay_url, disable_negentropy, None, None)
.await
}
/// Start relay with extended options (internal)
@@ -142,6 +184,7 @@ impl TestRelay {
bootstrap_relay_url: Option<String>,
disable_negentropy: bool,
retention_secs: Option<u64>,
cleanup_interval_secs: Option<u64>,
) -> Self {
let bind_address = format!("127.0.0.1:{}", port);
let url = format!("ws://127.0.0.1:{}", port);
@@ -200,6 +243,14 @@ impl TestRelay {
cmd.env("NGIT_ARCHIVE_RETENTION_SECS", retention.to_string());
}
// Add custom cleanup interval if provided
if let Some(cleanup_interval) = cleanup_interval_secs {
cmd.env(
"NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS",
cleanup_interval.to_string(),
);
}
let process = cmd.spawn().expect("Failed to start relay process");
let relay = Self { process, url, port };
+803
View File
@@ -0,0 +1,803 @@
//! NIP-09 Git Archival and Cleanup Integration Tests
//!
//! Tests end-to-end archival and cleanup functionality for NIP-09 deletion requests.
//!
//! # Test Coverage
//!
//! - Archive creation from git repositories
//! - Archive extraction and integrity verification
//! - Background cleanup of expired archives
//! - Startup cleanup of pre-existing expired data
//! - Cleanup with missing archive files
//! - Disk space reclamation tracking
//!
//! # Running Tests
//!
//! ```bash
//! # Run all archival tests
//! cargo test --test nip09_archival
//!
//! # Run specific test
//! cargo test --test nip09_archival test_archive_creation
//!
//! # With output
//! cargo test --test nip09_archival -- --nocapture
//! ```
mod common;
use flate2::read::GzDecoder;
use ngit_grasp::config::DatabaseBackend;
use ngit_grasp::database::{DeletionMetadata, HoldingDatabase};
use nostr_sdk::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use tar::Archive;
use tempfile::TempDir;
/// Helper: Get current Unix timestamp
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
/// Helper: Create a test git repository with actual commits
fn create_test_git_repo(base_dir: &Path, repo_name: &str) -> PathBuf {
let repo_path = base_dir.join(format!("{}.git", repo_name));
// Initialize bare repository
Command::new("git")
.args(["init", "--bare", repo_path.to_str().unwrap()])
.output()
.expect("Failed to init bare repo");
// Create a working directory to make commits
let work_dir = base_dir.join(format!("{}-work", repo_name));
Command::new("git")
.args([
"clone",
repo_path.to_str().unwrap(),
work_dir.to_str().unwrap(),
])
.output()
.expect("Failed to clone repo");
// Configure git
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&work_dir)
.output()
.expect("Failed to configure git email");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&work_dir)
.output()
.expect("Failed to configure git name");
Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.current_dir(&work_dir)
.output()
.expect("Failed to disable gpg signing");
// Create multiple files for better testing
fs::write(
work_dir.join("README.md"),
"# Test Repository\n\nThis is a test repository for archival testing.",
)
.unwrap();
// Create src directory first
fs::create_dir_all(work_dir.join("src")).unwrap();
fs::write(
work_dir.join("src/main.rs"),
"fn main() {\n println!(\"Hello, world!\");\n}\n",
)
.unwrap();
fs::write(work_dir.join(".gitignore"), "target/\n*.swp\n").unwrap();
// Add and commit
Command::new("git")
.args(["add", "."])
.current_dir(&work_dir)
.output()
.expect("Failed to git add");
Command::new("git")
.args(["commit", "-m", "Initial commit with multiple files"])
.current_dir(&work_dir)
.output()
.expect("Failed to git commit");
// Push to bare repo
Command::new("git")
.args(["push", "origin", "master"])
.current_dir(&work_dir)
.output()
.expect("Failed to git push");
repo_path
}
/// Helper: Get directory size in bytes (recursive)
fn get_dir_size(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter_map(|e| fs::metadata(e.path()).ok())
.map(|m| m.len())
.sum()
}
#[tokio::test]
async fn test_archive_creation() {
use ngit_grasp::git::archive::{archive_repository, create_archive_metadata};
let temp_dir = TempDir::new().unwrap();
let repo_path = create_test_git_repo(temp_dir.path(), "test-repo");
let archive_base = temp_dir.path().join(".archive");
let npub = "npub1test123456789";
let identifier = "test-repo";
let timestamp = current_timestamp();
// Create archive
let archive_path = archive_repository(&repo_path, &archive_base, npub, identifier, timestamp)
.expect("Failed to create archive");
// Verify archive file exists at correct path
assert!(archive_path.exists(), "Archive file should exist");
assert_eq!(
archive_path,
archive_base
.join(npub)
.join(format!("{}-{}.tar.gz", identifier, timestamp)),
"Archive path should match expected format"
);
// Create metadata
let deletion_event_id = "event123".to_string();
let retention_secs = 2592000; // 30 days
let metadata_path = create_archive_metadata(
&archive_path,
timestamp,
deletion_event_id.clone(),
npub.to_string(),
identifier.to_string(),
retention_secs,
)
.expect("Failed to create metadata");
// Verify metadata file exists
assert!(metadata_path.exists(), "Metadata file should exist");
assert_eq!(
metadata_path,
archive_path.with_extension("tar.gz.json"),
"Metadata path should have .tar.gz.json extension"
);
// Verify metadata content
let metadata_json = fs::read_to_string(&metadata_path).unwrap();
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap();
assert_eq!(metadata["deletion_timestamp"], timestamp);
assert_eq!(metadata["deletion_event_id"], deletion_event_id);
assert_eq!(metadata["maintainer_pubkey"], npub);
assert_eq!(metadata["identifier"], identifier);
assert_eq!(metadata["expiry_timestamp"], timestamp + retention_secs);
assert!(metadata["archive_file_path"]
.as_str()
.unwrap()
.contains(".archive"));
}
#[tokio::test]
async fn test_archive_extraction() {
use ngit_grasp::git::archive::archive_repository;
let temp_dir = TempDir::new().unwrap();
let repo_path = create_test_git_repo(temp_dir.path(), "extract-test");
let archive_base = temp_dir.path().join(".archive");
let npub = "npub1extract";
let identifier = "extract-test";
let timestamp = current_timestamp();
// Create archive
let archive_path = archive_repository(&repo_path, &archive_base, npub, identifier, timestamp)
.expect("Failed to create archive");
// Extract archive to verify integrity
let extract_dir = temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let file = fs::File::open(&archive_path).unwrap();
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
archive.unpack(&extract_dir).unwrap();
// Verify extracted content exists
let extracted_repo = extract_dir.join("extract-test.git");
assert!(extracted_repo.exists(), "Extracted repository should exist");
// Verify it's a valid git repository
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&extracted_repo)
.output()
.unwrap();
assert!(
output.status.success(),
"Extracted directory should be a valid git repo"
);
// Verify all files are present
assert!(
extracted_repo.join("HEAD").exists(),
"HEAD file should exist"
);
assert!(
extracted_repo.join("refs").exists(),
"refs directory should exist"
);
assert!(
extracted_repo.join("objects").exists(),
"objects directory should exist"
);
}
#[tokio::test]
async fn test_background_cleanup_with_short_retention() {
use ngit_grasp::config::DatabaseBackend;
use ngit_grasp::database::{DeletionMetadata, HoldingDatabase};
let temp_dir = TempDir::new().unwrap();
let git_data_path = temp_dir.path().join("git-data");
fs::create_dir_all(&git_data_path).unwrap();
// Create holding database
let holding_db = HoldingDatabase::new(&git_data_path, DatabaseBackend::Memory)
.await
.expect("Failed to create holding database");
// Create archive directory and files
let archive_dir = git_data_path.join(".archive/npub1cleanup");
fs::create_dir_all(&archive_dir).unwrap();
let archive_path = archive_dir.join("cleanup-test-123456.tar.gz");
let metadata_path = archive_dir.join("cleanup-test-123456.tar.gz.json");
fs::write(&archive_path, b"fake archive data for cleanup test").unwrap();
fs::write(&metadata_path, b"{}").unwrap();
// Verify files exist before cleanup
assert!(
archive_path.exists(),
"Archive file should exist before cleanup"
);
assert!(
metadata_path.exists(),
"Metadata file should exist before cleanup"
);
// Create test event with expired timestamp
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion request")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("event to be deleted")
.sign_with_keys(&keys)
.unwrap();
// Store event with expired metadata (expired 1 hour ago)
let metadata = DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600, // Expired 1 hour ago
archive_path: Some(".archive/npub1cleanup/cleanup-test-123456.tar.gz".to_string()),
};
holding_db.store_event(&event, metadata).await.unwrap();
// Verify event was stored and is expired
let expired = holding_db.query_expired(now).await.unwrap();
assert_eq!(expired.len(), 1, "Should have 1 expired event");
// Run cleanup
let result = ngit_grasp::database::cleanup_archived_data(&holding_db, &git_data_path)
.await
.expect("Cleanup should succeed");
// Verify cleanup results
// Note: events_deleted will be 0 until delete_event is fully implemented
// See src/database/holding.rs:211-229 for TODO
assert_eq!(result.archives_deleted, 1, "Should delete 1 archive");
assert_eq!(result.metadata_deleted, 1, "Should delete 1 metadata file");
assert!(result.bytes_reclaimed > 0, "Should reclaim disk space");
// Verify files were deleted
assert!(!archive_path.exists(), "Archive file should be deleted");
assert!(!metadata_path.exists(), "Metadata file should be deleted");
// Note: Event deletion from holding database is not yet implemented
// When implemented, we should verify: expired_after.len() == 0
}
#[tokio::test]
async fn test_startup_cleanup() {
// This test verifies that cleanup runs on relay startup
// We'll create expired data, then start a relay and verify it gets cleaned up
let temp_dir = TempDir::new().unwrap();
let git_data_path = temp_dir.path().join("git-data");
fs::create_dir_all(&git_data_path).unwrap();
// Create holding database with expired events BEFORE starting relay
let holding_db = HoldingDatabase::new(&git_data_path, DatabaseBackend::Memory)
.await
.expect("Failed to create holding database");
// Create archive files
let archive_dir = git_data_path.join(".archive/npub1startup");
fs::create_dir_all(&archive_dir).unwrap();
let archive_path = archive_dir.join("startup-test-123456.tar.gz");
let metadata_path = archive_dir.join("startup-test-123456.tar.gz.json");
fs::write(&archive_path, b"fake archive data for startup cleanup test").unwrap();
fs::write(&metadata_path, b"{}").unwrap();
// Create expired event
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion request")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("event to be deleted on startup")
.sign_with_keys(&keys)
.unwrap();
let metadata = DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600, // Expired 1 hour ago
archive_path: Some(".archive/npub1startup/startup-test-123456.tar.gz".to_string()),
};
holding_db.store_event(&event, metadata).await.unwrap();
// Verify files exist before relay starts
assert!(
archive_path.exists(),
"Archive file should exist before relay starts"
);
assert!(
metadata_path.exists(),
"Metadata file should exist before relay starts"
);
// Note: In a real integration test with TestRelay, we would:
// 1. Start the relay (which runs cleanup on startup)
// 2. Wait for cleanup to complete
// 3. Verify files are deleted
//
// However, TestRelay uses in-memory database and temporary directories,
// so we can't easily test startup cleanup with pre-existing data.
// This test demonstrates the pattern for manual testing.
// For now, manually run cleanup to verify it works
let result = ngit_grasp::database::cleanup_archived_data(&holding_db, &git_data_path)
.await
.expect("Cleanup should succeed");
assert_eq!(result.events_deleted, 1, "Should delete 1 event on startup");
assert!(
!archive_path.exists(),
"Archive file should be deleted on startup"
);
assert!(
!metadata_path.exists(),
"Metadata file should be deleted on startup"
);
}
#[tokio::test]
async fn test_cleanup_with_missing_files() {
use ngit_grasp::config::DatabaseBackend;
use ngit_grasp::database::{DeletionMetadata, HoldingDatabase};
let temp_dir = TempDir::new().unwrap();
let git_data_path = temp_dir.path().join("git-data");
fs::create_dir_all(&git_data_path).unwrap();
let holding_db = HoldingDatabase::new(&git_data_path, DatabaseBackend::Memory)
.await
.expect("Failed to create holding database");
// Create expired event with archive path pointing to non-existent files
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion request")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("event with missing archive")
.sign_with_keys(&keys)
.unwrap();
let metadata = DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600, // Expired 1 hour ago
archive_path: Some(".archive/npub1missing/nonexistent-123456.tar.gz".to_string()),
};
holding_db.store_event(&event, metadata).await.unwrap();
// Run cleanup - should not error even though files don't exist
let result = ngit_grasp::database::cleanup_archived_data(&holding_db, &git_data_path)
.await
.expect("Cleanup should succeed even with missing files");
// Files didn't exist, so deletion counts should be 0
assert_eq!(
result.archives_deleted, 0,
"Should not count missing archive as deleted"
);
assert_eq!(
result.metadata_deleted, 0,
"Should not count missing metadata as deleted"
);
// Note: Event deletion from holding database is not yet implemented
// When implemented, we should verify that cleanup still processes the event
// even when files are missing
}
#[tokio::test]
async fn test_disk_space_reclamation() {
use ngit_grasp::config::DatabaseBackend;
use ngit_grasp::database::{DeletionMetadata, HoldingDatabase};
use ngit_grasp::git::archive::archive_repository;
let temp_dir = TempDir::new().unwrap();
let git_data_path = temp_dir.path().join("git-data");
fs::create_dir_all(&git_data_path).unwrap();
// Create a large test git repository
let repo_path = create_test_git_repo(&git_data_path, "large-repo");
// Add more files to make it larger
let work_dir = git_data_path.join("large-repo-work");
Command::new("git")
.args([
"clone",
repo_path.to_str().unwrap(),
work_dir.to_str().unwrap(),
])
.output()
.expect("Failed to clone repo");
// Configure git
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.current_dir(&work_dir)
.output()
.unwrap();
// Add multiple large files
for i in 0..5 {
let content = format!("Large file content {}\n", i).repeat(1000);
fs::write(work_dir.join(format!("large-file-{}.txt", i)), content).unwrap();
}
Command::new("git")
.args(["add", "."])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add large files"])
.current_dir(&work_dir)
.output()
.unwrap();
Command::new("git")
.args(["push", "origin", "master"])
.current_dir(&work_dir)
.output()
.unwrap();
// Create archive
let archive_base = git_data_path.join(".archive");
let npub = "npub1diskspace";
let identifier = "large-repo";
let timestamp = current_timestamp();
let archive_path = archive_repository(&repo_path, &archive_base, npub, identifier, timestamp)
.expect("Failed to create archive");
// Measure archive size
let archive_size = fs::metadata(&archive_path).unwrap().len();
assert!(archive_size > 1000, "Archive should be reasonably large");
// Create metadata file
let metadata_path = PathBuf::from(format!("{}.json", archive_path.display()));
fs::write(&metadata_path, b"{}").unwrap();
let metadata_size = fs::metadata(&metadata_path).unwrap().len();
// Create holding database and store expired event
let holding_db = HoldingDatabase::new(&git_data_path, DatabaseBackend::Memory)
.await
.expect("Failed to create holding database");
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion request")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let now = current_timestamp();
let event = EventBuilder::text_note("event to be deleted")
.sign_with_keys(&keys)
.unwrap();
let metadata = DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600,
archive_path: Some(format!(
".archive/{}/{}-{}.tar.gz",
npub, identifier, timestamp
)),
};
holding_db.store_event(&event, metadata).await.unwrap();
// Measure disk usage before cleanup
let disk_usage_before = get_dir_size(&archive_base);
assert!(
disk_usage_before > 0,
"Should have disk usage before cleanup"
);
// Run cleanup
let result = ngit_grasp::database::cleanup_archived_data(&holding_db, &git_data_path)
.await
.expect("Cleanup should succeed");
// Verify disk space was reclaimed
// Allow small variance due to file system metadata
let expected_bytes = archive_size + metadata_size;
assert!(
result.bytes_reclaimed >= expected_bytes - 10
&& result.bytes_reclaimed <= expected_bytes + 10,
"Should reclaim approximately {} bytes (got {})",
expected_bytes,
result.bytes_reclaimed
);
// Measure disk usage after cleanup
let disk_usage_after = if archive_base.exists() {
get_dir_size(&archive_base)
} else {
0
};
assert!(
disk_usage_after < disk_usage_before,
"Disk usage should decrease after cleanup"
);
// Verify files are gone
assert!(!archive_path.exists(), "Archive file should be deleted");
assert!(!metadata_path.exists(), "Metadata file should be deleted");
}
#[tokio::test]
async fn test_cleanup_multiple_expired_events() {
use ngit_grasp::config::DatabaseBackend;
use ngit_grasp::database::{DeletionMetadata, HoldingDatabase};
let temp_dir = TempDir::new().unwrap();
let git_data_path = temp_dir.path().join("git-data");
fs::create_dir_all(&git_data_path).unwrap();
let holding_db = HoldingDatabase::new(&git_data_path, DatabaseBackend::Memory)
.await
.expect("Failed to create holding database");
// Create multiple archive files
let archive_dir = git_data_path.join(".archive/npub1multiple");
fs::create_dir_all(&archive_dir).unwrap();
let now = current_timestamp();
let mut expected_bytes = 0u64;
// Create 5 expired events with archives
for i in 0..5 {
let archive_path = archive_dir.join(format!("repo-{}-{}.tar.gz", i, now));
let metadata_path = archive_dir.join(format!("repo-{}-{}.tar.gz.json", i, now));
let archive_content = format!("fake archive data {}", i);
fs::write(&archive_path, archive_content.as_bytes()).unwrap();
fs::write(&metadata_path, b"{}").unwrap();
expected_bytes += archive_content.len() as u64 + 2; // +2 for "{}"
let keys = Keys::generate();
let deletion_keys = Keys::generate();
let deletion_event_id = EventBuilder::text_note("deletion request")
.sign_with_keys(&deletion_keys)
.unwrap()
.id;
let event = EventBuilder::text_note(format!("event {}", i))
.sign_with_keys(&keys)
.unwrap();
let metadata = DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id,
expiry_timestamp: now - 3600,
archive_path: Some(format!(".archive/npub1multiple/repo-{}-{}.tar.gz", i, now)),
};
holding_db.store_event(&event, metadata).await.unwrap();
}
// Verify all events are expired
let expired = holding_db.query_expired(now).await.unwrap();
assert_eq!(expired.len(), 5, "Should have 5 expired events");
// Run cleanup
let result = ngit_grasp::database::cleanup_archived_data(&holding_db, &git_data_path)
.await
.expect("Cleanup should succeed");
// Verify all files were cleaned up
// Note: events_deleted will be 0 until delete_event is fully implemented
assert_eq!(result.archives_deleted, 5, "Should delete 5 archives");
assert_eq!(result.metadata_deleted, 5, "Should delete 5 metadata files");
assert_eq!(
result.bytes_reclaimed, expected_bytes,
"Should reclaim expected bytes"
);
// Note: Event deletion from holding database is not yet implemented
// When implemented, we should verify: expired_after.len() == 0
}
#[tokio::test]
async fn test_cleanup_preserves_non_expired_events() {
use ngit_grasp::config::DatabaseBackend;
use ngit_grasp::database::{DeletionMetadata, HoldingDatabase};
let temp_dir = TempDir::new().unwrap();
let git_data_path = temp_dir.path().join("git-data");
fs::create_dir_all(&git_data_path).unwrap();
let holding_db = HoldingDatabase::new(&git_data_path, DatabaseBackend::Memory)
.await
.expect("Failed to create holding database");
let archive_dir = git_data_path.join(".archive/npub1preserve");
fs::create_dir_all(&archive_dir).unwrap();
let now = current_timestamp();
// Create one expired event
let expired_archive = archive_dir.join("expired-123.tar.gz");
let expired_metadata = archive_dir.join("expired-123.tar.gz.json");
fs::write(&expired_archive, b"expired archive").unwrap();
fs::write(&expired_metadata, b"{}").unwrap();
let keys1 = Keys::generate();
let deletion_keys1 = Keys::generate();
let deletion_event_id1 = EventBuilder::text_note("deletion request 1")
.sign_with_keys(&deletion_keys1)
.unwrap()
.id;
let event1 = EventBuilder::text_note("expired event")
.sign_with_keys(&keys1)
.unwrap();
let metadata1 = DeletionMetadata {
deletion_timestamp: now - 7200,
deletion_event_id: deletion_event_id1,
expiry_timestamp: now - 3600, // Expired
archive_path: Some(".archive/npub1preserve/expired-123.tar.gz".to_string()),
};
holding_db.store_event(&event1, metadata1).await.unwrap();
// Create one non-expired event
let active_archive = archive_dir.join("active-456.tar.gz");
let active_metadata = archive_dir.join("active-456.tar.gz.json");
fs::write(&active_archive, b"active archive").unwrap();
fs::write(&active_metadata, b"{}").unwrap();
let keys2 = Keys::generate();
let deletion_keys2 = Keys::generate();
let deletion_event_id2 = EventBuilder::text_note("deletion request 2")
.sign_with_keys(&deletion_keys2)
.unwrap()
.id;
let event2 = EventBuilder::text_note("active event")
.sign_with_keys(&keys2)
.unwrap();
let metadata2 = DeletionMetadata {
deletion_timestamp: now,
deletion_event_id: deletion_event_id2,
expiry_timestamp: now + 3600, // Not expired yet
archive_path: Some(".archive/npub1preserve/active-456.tar.gz".to_string()),
};
holding_db.store_event(&event2, metadata2).await.unwrap();
// Run cleanup
let result = ngit_grasp::database::cleanup_archived_data(&holding_db, &git_data_path)
.await
.expect("Cleanup should succeed");
// Verify only expired files were cleaned up
// Note: events_deleted will be 0 until delete_event is fully implemented
assert_eq!(
result.archives_deleted, 1,
"Should delete only 1 expired archive"
);
// Verify expired files are gone
assert!(
!expired_archive.exists(),
"Expired archive should be deleted"
);
assert!(
!expired_metadata.exists(),
"Expired metadata should be deleted"
);
// Verify active files still exist
assert!(active_archive.exists(), "Active archive should still exist");
assert!(
active_metadata.exists(),
"Active metadata should still exist"
);
// Verify both events are still in database (since delete_event is not implemented)
// When delete_event is implemented, we should verify:
// - expired.len() == 0 (expired event deleted)
// - future_expired.len() == 1 (active event preserved)
let all_events = holding_db.query_expired(now + 7200).await.unwrap();
assert_eq!(
all_events.len(),
2,
"Both events should still be in database (delete_event not yet implemented)"
);
}