From d67b6f339b0ce3725d8daf173e1bd047de7eb9d5 Mon Sep 17 00:00:00 2001
From: dawn <dawn@tangled.org>
Date: Mon, 2 Nov 2026 00:34:58 +0300
Subject: [PATCH] server: add delete-paths endpoint
---
attic/src/api/v1/delete_paths.rs | 25 +++++++++
attic/src/api/v1/mod.rs | 1 +
server/src/api/v1/delete_paths.rs | 90 +++++++++++++++++++++++++++++++
server/src/api/v1/mod.rs | 2 +
4 files changed, 118 insertions(+)
create mode 100644 attic/src/api/v1/delete_paths.rs
create mode 100644 server/src/api/v1/delete_paths.rs
diff --git a/attic/src/api/v1/delete_paths.rs b/attic/src/api/v1/delete_paths.rs
new file mode 100644
index 0000000..ce2a27d
--- /dev/null
+++ b/attic/src/api/v1/delete_paths.rs
@@ -0,0 +1,25 @@
+//! delete-paths v1
+//!
+//! `POST /_api/v1/delete-paths`
+//!
+//! Requires "delete" permission.
+
+use serde::{Deserialize, Serialize};
+
+use crate::cache::CacheName;
+use crate::nix_store::StorePathHash;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DeletePathsRequest {
+ /// The name of the cache.
+ pub cache: CacheName,
+
+ /// The list of store paths to delete.
+ pub store_path_hashes: Vec<StorePathHash>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DeletePathsResponse {
+ /// The paths that were in the cache and have been deleted.
+ pub deleted: Vec<StorePathHash>,
+}
diff --git a/attic/src/api/v1/mod.rs b/attic/src/api/v1/mod.rs
index 6c0cc4e..eee2036 100644
--- a/attic/src/api/v1/mod.rs
+++ b/attic/src/api/v1/mod.rs
@@ -1,3 +1,4 @@
pub mod cache_config;
+pub mod delete_paths;
pub mod get_missing_paths;
pub mod upload_path;
diff --git a/server/src/api/v1/delete_paths.rs b/server/src/api/v1/delete_paths.rs
new file mode 100644
index 0000000..118d3b8
--- /dev/null
+++ b/server/src/api/v1/delete_paths.rs
@@ -0,0 +1,90 @@
+use std::collections::HashSet;
+
+use anyhow::anyhow;
+use axum::extract::{Extension, Json};
+use sea_orm::entity::prelude::*;
+use sea_orm::TransactionTrait;
+use tracing::instrument;
+
+use crate::database::entity::object::{self, Entity as Object};
+use crate::error::{ErrorKind, ServerError, ServerResult};
+use crate::{RequestState, State};
+use attic::api::v1::delete_paths::{DeletePathsRequest, DeletePathsResponse};
+use attic::nix_store::StorePathHash;
+
+/// The most store paths one request may name.
+const MAX_STORE_PATHS: usize = 10000;
+
+/// Deletes store paths from a cache.
+///
+/// Requires "delete" permission. Only the objects are removed, the
+/// garbage collector reaps the NARs and chunks left without one.
+#[instrument(skip_all, fields(payload))]
+pub(crate) async fn delete_paths(
+ Extension(state): Extension<State>,
+ Extension(req_state): Extension<RequestState>,
+ Json(payload): Json<DeletePathsRequest>,
+) -> ServerResult<Json<DeletePathsResponse>> {
+ let database = state.database().await?;
+ let cache = req_state
+ .auth
+ .auth_cache(database, &payload.cache, |cache, permission| {
+ permission.require_delete()?;
+ Ok(cache)
+ })
+ .await?;
+
+ if payload.store_path_hashes.len() > MAX_STORE_PATHS {
+ return Err(ErrorKind::RequestError(anyhow!(
+ "At most {} store paths can be deleted per request",
+ MAX_STORE_PATHS
+ ))
+ .into());
+ }
+
+ if payload.store_path_hashes.is_empty() {
+ return Ok(Json(DeletePathsResponse { deleted: vec![] }));
+ }
+
+ let requested_hashes: HashSet<String> = payload
+ .store_path_hashes
+ .iter()
+ .map(|h| h.as_str().to_owned())
+ .collect();
+
+ let txn = database
+ .begin()
+ .await
+ .map_err(ServerError::database_error)?;
+
+ let found = Object::find()
+ .filter(object::Column::CacheId.eq(cache.id))
+ .filter(object::Column::StorePathHash.is_in(requested_hashes))
+ .all(&txn)
+ .await
+ .map_err(ServerError::database_error)?;
+
+ if found.is_empty() {
+ return Ok(Json(DeletePathsResponse { deleted: vec![] }));
+ }
+
+ // Delete by ID so an object uploaded after the select is neither
+ // deleted nor missing from the response.
+ Object::delete_many()
+ .filter(object::Column::Id.is_in(found.iter().map(|o| o.id)))
+ .exec(&txn)
+ .await
+ .map_err(ServerError::database_error)?;
+
+ txn.commit().await.map_err(ServerError::database_error)?;
+
+ // Safety: All found hashes come from validated `StorePathHash`es.
+ // No need to pay the cost of checking again
+ #[allow(unsafe_code)]
+ let deleted = found
+ .into_iter()
+ .map(|o| unsafe { StorePathHash::new_unchecked(o.store_path_hash) })
+ .collect();
+
+ Ok(Json(DeletePathsResponse { deleted }))
+}
diff --git a/server/src/api/v1/mod.rs b/server/src/api/v1/mod.rs
index bc34fd7..6be575c 100644
--- a/server/src/api/v1/mod.rs
+++ b/server/src/api/v1/mod.rs
@@ -1,4 +1,5 @@
mod cache_config;
+mod delete_paths;
mod get_missing_paths;
mod upload_path;
@@ -13,6 +14,7 @@ pub(crate) fn get_router() -> Router {
"/_api/v1/get-missing-paths",
post(get_missing_paths::get_missing_paths),
)
+ .route("/_api/v1/delete-paths", post(delete_paths::delete_paths))
.route("/_api/v1/upload-path", put(upload_path::upload_path))
.route(
"/:cache/attic-cache-info",
--
2.55.0