mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: add orphan blob cleanup to Settings > Storage tab (#18)
- database.rs: add has_active_capture_jobs(), list_orphaned_blob_rows(),
all_referenced_file_relpaths(), delete_orphaned_blob_rows()
- routes.rs: GET/DELETE /api/archives/:id/blob-cleanup (ROLE_ADMIN)
- GET returns {orphaned_blob_rows, deletable_files, total_bytes}
- DELETE has two active-capture guards (before and after disk walk)
to prevent deleting files mid-capture; walks raw/ and raw_tweets/
- Referenced set = entry_artifacts.relpath ∪ live blobs' raw_relpath,
so a file is never deleted if any artifact still points at its path
- api.js: scanOrphanBlobs(), deleteOrphanBlobs()
- App.jsx: pass archiveId to SettingsView; add 'storage' to SETTINGS_TABS
so /settings/storage survives refresh/back navigation
- SettingsView.jsx: new Storage tab (admin-only) with idle→scanning→
scanned→deleting→done/error state machine; shows file/record counts
and human-readable byte sizes before a btn-danger confirm
Tests (14 new, 248 total passing):
- database.rs: has_active_capture_jobs for pending/running/completed,
list_orphaned_blob_rows, all_referenced_file_relpaths edge cases,
delete_orphaned_blob_rows preserves referenced rows
- routes.rs: auth (401), active-capture 409 on GET and DELETE,
end-to-end delete preserving referenced file and removing orphan
blob file + extra disk-only file
This commit is contained in:
parent
b8e496457f
commit
339076e6a2
8 changed files with 739 additions and 48 deletions
|
|
@ -1488,6 +1488,76 @@ pub fn get_blob_by_sha256(conn: &Connection, sha256: &str) -> Result<Option<Blob
|
|||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
/// Returns `true` if any capture job in this archive is `pending` or `running`.
|
||||
/// Call before scanning or deleting orphans: the capture pipeline moves files into
|
||||
/// `raw/` before writing the DB rows, so a mid-capture scan can falsely flag live files.
|
||||
pub fn has_active_capture_jobs(conn: &Connection) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM capture_jobs WHERE status IN ('pending', 'running')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Returns `(id, raw_relpath, byte_size)` for every blob row not referenced by any
|
||||
/// `entry_artifacts.blob_id`. These DB rows are safe to delete regardless of whether
|
||||
/// a disk file still exists at their `raw_relpath`.
|
||||
pub fn list_orphaned_blob_rows(conn: &Connection) -> Result<Vec<(i64, String, i64)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, raw_relpath, byte_size FROM blobs \
|
||||
WHERE id NOT IN \
|
||||
(SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Returns the set of all file relpaths (relative to `store_path`) that are currently
|
||||
/// referenced by at least one live entry_artifact, either directly via
|
||||
/// `entry_artifacts.relpath` or indirectly via a live blob's `raw_relpath`.
|
||||
/// Any disk file whose relpath is in this set must NOT be deleted.
|
||||
pub fn all_referenced_file_relpaths(conn: &Connection) -> Result<std::collections::HashSet<String>> {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
{
|
||||
let mut stmt = conn.prepare("SELECT DISTINCT relpath FROM entry_artifacts")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
set.insert(row.get::<_, String>(0)?);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT raw_relpath FROM blobs \
|
||||
WHERE id IN \
|
||||
(SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
set.insert(row.get::<_, String>(0)?);
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Delete every blob row not referenced by any `entry_artifacts.blob_id`.
|
||||
/// Returns the number of rows deleted.
|
||||
pub fn delete_orphaned_blob_rows(conn: &Connection) -> Result<usize> {
|
||||
Ok(conn.execute(
|
||||
"DELETE FROM blobs WHERE id NOT IN \
|
||||
(SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)",
|
||||
[],
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<ArchivedEntry> {
|
||||
validate_visibility(&entry.visibility)?;
|
||||
let entry_uid = public_id("entry");
|
||||
|
|
@ -2931,4 +3001,169 @@ mod tests {
|
|||
assert_eq!(after, 0, "cached_bytes must be 0 after whole subtree is deleted");
|
||||
}
|
||||
|
||||
// ── Orphan blob cleanup ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn has_active_capture_jobs_false_when_none() {
|
||||
let conn = conn();
|
||||
assert!(!has_active_capture_jobs(&conn).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_active_capture_jobs_true_for_pending() {
|
||||
let conn = conn();
|
||||
create_capture_job(&conn, "test").unwrap();
|
||||
assert!(has_active_capture_jobs(&conn).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_active_capture_jobs_true_for_running() {
|
||||
let conn = conn();
|
||||
let uid = create_capture_job(&conn, "test").unwrap();
|
||||
update_capture_job_status(&conn, &uid, "running", None, None).unwrap();
|
||||
assert!(has_active_capture_jobs(&conn).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_active_capture_jobs_false_for_completed() {
|
||||
let conn = conn();
|
||||
let uid = create_capture_job(&conn, "test").unwrap();
|
||||
update_capture_job_status(&conn, &uid, "completed", Some("run_x"), None).unwrap();
|
||||
assert!(!has_active_capture_jobs(&conn).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_orphaned_blob_rows_empty_when_blob_is_referenced() {
|
||||
let conn = conn();
|
||||
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||
let blob = BlobRecord {
|
||||
sha256: "aaa111".to_string(),
|
||||
byte_size: 100,
|
||||
mime_type: None,
|
||||
extension: Some("mp4".to_string()),
|
||||
raw_relpath: "raw/a/a/aaa111.mp4".to_string(),
|
||||
};
|
||||
let blob_id = upsert_blob(&conn, &blob).unwrap();
|
||||
add_entry_artifact(&conn, &NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "main".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: blob.raw_relpath.clone(),
|
||||
blob_id: Some(blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
}).unwrap();
|
||||
assert!(list_orphaned_blob_rows(&conn).unwrap().is_empty(),
|
||||
"referenced blob must not appear as orphan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_orphaned_blob_rows_finds_unreferenced_blob() {
|
||||
let conn = conn();
|
||||
upsert_blob(&conn, &BlobRecord {
|
||||
sha256: "bbb222".to_string(),
|
||||
byte_size: 200,
|
||||
mime_type: None,
|
||||
extension: Some("jpg".to_string()),
|
||||
raw_relpath: "raw/b/b/bbb222.jpg".to_string(),
|
||||
}).unwrap();
|
||||
let orphans = list_orphaned_blob_rows(&conn).unwrap();
|
||||
assert_eq!(orphans.len(), 1, "unreferenced blob must appear as orphan");
|
||||
assert_eq!(orphans[0].1, "raw/b/b/bbb222.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_referenced_file_relpaths_covers_blob_and_direct_artifact_relpaths() {
|
||||
let conn = conn();
|
||||
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||
// Live blob: linked via blob_id
|
||||
let blob = BlobRecord {
|
||||
sha256: "live1".to_string(), byte_size: 50,
|
||||
mime_type: None, extension: None,
|
||||
raw_relpath: "raw/l/i/live1".to_string(),
|
||||
};
|
||||
let blob_id = upsert_blob(&conn, &blob).unwrap();
|
||||
add_entry_artifact(&conn, &NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "main".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: blob.raw_relpath.clone(),
|
||||
blob_id: Some(blob_id),
|
||||
logical_path: None, metadata_json: None,
|
||||
}).unwrap();
|
||||
// Artifact referencing a file directly (no blob_id)
|
||||
add_entry_artifact(&conn, &NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "sidecar".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: "raw/s/i/sidecar.vtt".to_string(),
|
||||
blob_id: None,
|
||||
logical_path: None, metadata_json: None,
|
||||
}).unwrap();
|
||||
let refs = all_referenced_file_relpaths(&conn).unwrap();
|
||||
assert!(refs.contains("raw/l/i/live1"), "live blob relpath must be protected");
|
||||
assert!(refs.contains("raw/s/i/sidecar.vtt"), "direct artifact relpath must be protected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_orphaned_blob_rows_removes_only_unreferenced() {
|
||||
let conn = conn();
|
||||
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||
// Referenced blob
|
||||
let live = BlobRecord {
|
||||
sha256: "live9999".to_string(), byte_size: 10,
|
||||
mime_type: None, extension: None,
|
||||
raw_relpath: "raw/l/v/live9999".to_string(),
|
||||
};
|
||||
let live_id = upsert_blob(&conn, &live).unwrap();
|
||||
add_entry_artifact(&conn, &NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "main".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: live.raw_relpath.clone(),
|
||||
blob_id: Some(live_id),
|
||||
logical_path: None, metadata_json: None,
|
||||
}).unwrap();
|
||||
// Orphaned blob
|
||||
upsert_blob(&conn, &BlobRecord {
|
||||
sha256: "dead0000".to_string(), byte_size: 20,
|
||||
mime_type: None, extension: None,
|
||||
raw_relpath: "raw/d/e/dead0000".to_string(),
|
||||
}).unwrap();
|
||||
let deleted = delete_orphaned_blob_rows(&conn).unwrap();
|
||||
assert_eq!(deleted, 1, "only the unreferenced blob row should be deleted");
|
||||
assert!(get_blob_by_sha256(&conn, "live9999").unwrap().is_some(),
|
||||
"referenced blob row must be preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_blob_row_whose_relpath_is_artifact_relpath_stays_in_referenced_set() {
|
||||
// Blob row has no blob_id reference (would be deleted from DB),
|
||||
// but an artifact points to the same file via relpath — the file must
|
||||
// appear in all_referenced_file_relpaths so it won't be deleted from disk.
|
||||
let conn = conn();
|
||||
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||
let blob = BlobRecord {
|
||||
sha256: "edgecase".to_string(), byte_size: 30,
|
||||
mime_type: None, extension: None,
|
||||
raw_relpath: "raw/e/d/edgecase".to_string(),
|
||||
};
|
||||
upsert_blob(&conn, &blob).unwrap();
|
||||
// artifact uses same relpath but no blob_id
|
||||
add_entry_artifact(&conn, &NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "sidecar".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: blob.raw_relpath.clone(),
|
||||
blob_id: None,
|
||||
logical_path: None, metadata_json: None,
|
||||
}).unwrap();
|
||||
// blob row is orphaned (no blob_id reference)
|
||||
assert_eq!(list_orphaned_blob_rows(&conn).unwrap().len(), 1);
|
||||
// but the file relpath is still protected
|
||||
let refs = all_referenced_file_relpaths(&conn).unwrap();
|
||||
assert!(refs.contains(&blob.raw_relpath),
|
||||
"file must be protected because artifact.relpath references it directly");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,6 +287,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
"/api/archives/:archive_id/entries/:entry_uid/collections",
|
||||
get(list_entry_collections_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/blob-cleanup",
|
||||
get(blob_cleanup_scan_handler).delete(blob_cleanup_delete_handler),
|
||||
)
|
||||
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
||||
|
|
@ -994,6 +998,150 @@ async fn update_instance_settings_handler(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ── Blob / orphan cleanup ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct BlobCleanupScanResponse {
|
||||
orphaned_blob_rows: usize,
|
||||
deletable_files: usize,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// GET /api/archives/:archive_id/blob-cleanup
|
||||
/// Returns stats on orphaned blob DB rows and unreferenced raw files.
|
||||
/// Returns 409 if any capture job is pending or running.
|
||||
async fn blob_cleanup_scan_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<BlobCleanupScanResponse>, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let paths = archive::read_archive_paths(&mounted.archive_path)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
|
||||
if database::has_active_capture_jobs(&conn)? {
|
||||
return Err(ApiError::conflict(
|
||||
"captures are in progress; wait for them to finish before scanning",
|
||||
));
|
||||
}
|
||||
|
||||
let referenced = database::all_referenced_file_relpaths(&conn)?;
|
||||
let orphaned_blob_rows = database::list_orphaned_blob_rows(&conn)?.len();
|
||||
let orphaned_files = collect_orphaned_disk_files(&paths.store_path, &referenced)
|
||||
.map_err(|e| ApiError::internal(&format!("disk scan failed: {e:#}")))?;
|
||||
let total_bytes: u64 = orphaned_files.iter().map(|(_, sz)| sz).sum();
|
||||
|
||||
Ok(Json(BlobCleanupScanResponse {
|
||||
orphaned_blob_rows,
|
||||
deletable_files: orphaned_files.len(),
|
||||
total_bytes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// DELETE /api/archives/:archive_id/blob-cleanup
|
||||
/// Deletes orphaned blob DB rows and unreferenced raw files.
|
||||
/// Re-checks for active captures immediately before executing to close the TOCTOU window.
|
||||
async fn blob_cleanup_delete_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
auth_user.require_role(ROLE_ADMIN)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let paths = archive::read_archive_paths(&mounted.archive_path)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
|
||||
// Re-check immediately before acting (closes the TOCTOU gap between scan and delete).
|
||||
if database::has_active_capture_jobs(&conn)? {
|
||||
return Err(ApiError::conflict(
|
||||
"captures are in progress; wait for them to finish before cleaning up",
|
||||
));
|
||||
}
|
||||
|
||||
// Collect the set of protected relpaths and the files to delete BEFORE mutating the DB.
|
||||
// This ensures the referenced set is consistent with the rows we're about to remove.
|
||||
let referenced = database::all_referenced_file_relpaths(&conn)?;
|
||||
let files_to_delete = collect_orphaned_disk_files(&paths.store_path, &referenced)
|
||||
.map_err(|e| ApiError::internal(&format!("disk scan failed: {e:#}")))?;
|
||||
|
||||
// Second guard: re-check after the disk walk, which can be slow.
|
||||
// A capture that started during the walk may have moved files into raw/ before
|
||||
// writing its DB rows; those files would appear orphaned but must not be deleted.
|
||||
if database::has_active_capture_jobs(&conn)? {
|
||||
return Err(ApiError::conflict(
|
||||
"a capture started during the scan; retry after all captures finish",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Delete orphaned blob rows from the database.
|
||||
let deleted_blob_rows = database::delete_orphaned_blob_rows(&conn)?;
|
||||
|
||||
// Delete the unreferenced disk files.
|
||||
let mut freed_bytes: u64 = 0;
|
||||
let mut deleted_files: usize = 0;
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
for (path, size) in &files_to_delete {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => {
|
||||
freed_bytes += *size;
|
||||
deleted_files += 1;
|
||||
}
|
||||
Err(e) => errors.push(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"info: blob cleanup for '{}': {} blob rows, {} files deleted, {} bytes freed, {} errors",
|
||||
archive_id, deleted_blob_rows, deleted_files, freed_bytes, errors.len()
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"deleted_blob_rows": deleted_blob_rows,
|
||||
"deleted_files": deleted_files,
|
||||
"freed_bytes": freed_bytes,
|
||||
"errors": errors,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Walk `raw/` and `raw_tweets/` under `store_path` and return every file whose
|
||||
/// relpath (relative to `store_path`, forward-slash separated) is absent from
|
||||
/// `referenced`. Each entry is `(absolute_path, byte_size)`.
|
||||
fn collect_orphaned_disk_files(
|
||||
store_path: &std::path::Path,
|
||||
referenced: &std::collections::HashSet<String>,
|
||||
) -> anyhow::Result<Vec<(std::path::PathBuf, u64)>> {
|
||||
let mut result = Vec::new();
|
||||
for subdir in &["raw", "raw_tweets"] {
|
||||
let dir = store_path.join(subdir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![dir];
|
||||
while let Some(current) = stack.pop() {
|
||||
for entry in std::fs::read_dir(¤t)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let ft = entry.file_type()?;
|
||||
if ft.is_dir() {
|
||||
stack.push(path);
|
||||
} else if ft.is_file() {
|
||||
if let Ok(rel) = path.strip_prefix(store_path) {
|
||||
// Normalise to forward slashes (relevant on Windows if ever deployed there).
|
||||
let relpath = rel.to_string_lossy().replace('\\', "/");
|
||||
if !referenced.contains(&relpath) {
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
result.push((path, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -1210,6 +1358,10 @@ impl ApiError {
|
|||
pub fn forbidden(message: &str) -> Self {
|
||||
Self { status: StatusCode::FORBIDDEN, message: message.to_string() }
|
||||
}
|
||||
|
||||
fn conflict(message: &str) -> Self {
|
||||
Self { status: StatusCode::CONFLICT, message: message.to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for ApiError
|
||||
|
|
@ -3212,4 +3364,167 @@ mod tests {
|
|||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ── Blob cleanup route tests ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_cleanup_scan_requires_auth() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/blob-cleanup")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_cleanup_delete_requires_auth() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/archives/test/blob-cleanup")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_cleanup_scan_returns_409_when_capture_pending() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
{
|
||||
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||
database::create_capture_job(&conn, "test").unwrap();
|
||||
}
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/blob-cleanup")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_cleanup_delete_returns_409_when_capture_pending() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
{
|
||||
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||
database::create_capture_job(&conn, "test").unwrap();
|
||||
}
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/archives/test/blob-cleanup")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_cleanup_delete_removes_orphan_and_preserves_referenced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store_path = dir.path().join("store");
|
||||
let paths = archivr_core::archive::initialize_archive(
|
||||
dir.path(), &store_path, "test", false,
|
||||
).unwrap();
|
||||
let auth_path = dir.path().join("auth.sqlite");
|
||||
{
|
||||
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||
}
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_test_entry(&paths.archive_path);
|
||||
|
||||
let live_relpath = "raw/l/i/live.bin";
|
||||
let orphan_relpath = "raw/o/r/orphan.bin";
|
||||
let extra_relpath = "raw/x/t/extra.bin"; // on disk only, no blob row
|
||||
|
||||
{
|
||||
let conn = database::open_or_initialize(&paths.archive_path).unwrap();
|
||||
// Referenced blob
|
||||
let live_id = database::upsert_blob(&conn, &database::BlobRecord {
|
||||
sha256: "aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444".to_string(),
|
||||
byte_size: 10, mime_type: None, extension: Some("bin".to_string()),
|
||||
raw_relpath: live_relpath.to_string(),
|
||||
}).unwrap();
|
||||
database::add_entry_artifact(&conn, &database::NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "main".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: live_relpath.to_string(),
|
||||
blob_id: Some(live_id),
|
||||
logical_path: None, metadata_json: None,
|
||||
}).unwrap();
|
||||
// Orphaned blob (no artifact references it)
|
||||
database::upsert_blob(&conn, &database::BlobRecord {
|
||||
sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(),
|
||||
byte_size: 20, mime_type: None, extension: Some("bin".to_string()),
|
||||
raw_relpath: orphan_relpath.to_string(),
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
// Write all three files to disk
|
||||
for relpath in &[live_relpath, orphan_relpath, extra_relpath] {
|
||||
let abs = store_path.join(relpath);
|
||||
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
|
||||
std::fs::write(&abs, b"content").unwrap();
|
||||
}
|
||||
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "test".to_string(),
|
||||
label: "Test".to_string(),
|
||||
archive_path: paths.archive_path.clone(),
|
||||
}],
|
||||
bind: None,
|
||||
auth_db_path: None,
|
||||
};
|
||||
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/archives/test/blob-cleanup")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["deleted_blob_rows"], 1, "one orphaned DB row removed");
|
||||
assert_eq!(body["deleted_files"], 2, "orphan blob file and extra disk file removed");
|
||||
assert!(body["errors"].as_array().unwrap().is_empty());
|
||||
|
||||
assert!(store_path.join(live_relpath).exists(), "referenced file must be preserved");
|
||||
assert!(!store_path.join(orphan_relpath).exists(), "orphaned blob file must be deleted");
|
||||
assert!(!store_path.join(extra_relpath).exists(), "extra disk-only file must be deleted");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-bXPw5mQ7.js
Normal file
40
crates/archivr-server/static/assets/index-bXPw5mQ7.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-BHg5-TAr.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-bXPw5mQ7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue