prevent self-parenting

This commit is contained in:
Brandon Thomas committed 2026-06-08 10:58:43 -04:00
1 parent 2844314776
commit 3f2086d85b
2 files changed
+39 -13

No files matched your search

@@ -18,29 +18,46 @@ where
/// Set `maybe_parent_folder_token = new_parent_token` on every owned,
/// live folder whose token is in `child_tokens` AND that isn't the parent
/// itself. Returns the number of rows whose value actually changed.
/// itself (a folder can't be its own parent). Returns the number of rows
/// whose value actually changed.
///
/// Self-referential parenting is prevented at two layers:
/// * Rust-side: the parent is filtered out of the candidate list
/// before the SQL is built.
/// * SQL-side: the WHERE clause includes `AND token != new_parent_token`
/// as a defense-in-depth guard.
pub async fn bulk_set_parent_folder<'e, 'c: 'e, E>(
args: BulkSetParentFolderArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.child_tokens.is_empty() {
// Drop the parent from the candidate list. This makes the function
// self-protecting — callers don't have to remember to filter — and
// pairs with the SQL `token != new_parent_token` guard below.
let new_parent_str = args.new_parent_token.as_str();
let filtered_children: Vec<&FolderToken> = args.child_tokens
.iter()
.filter(|t| t.as_str() != new_parent_str)
.collect();
if filtered_children.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"UPDATE folders SET maybe_parent_folder_token = ",
);
builder.push_bind(args.new_parent_token.as_str());
builder.push_bind(new_parent_str);
builder.push(" WHERE owner_user_token = ");
builder.push_bind(args.owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND token != ");
builder.push_bind(args.new_parent_token.as_str());
builder.push_bind(new_parent_str);
builder.push(" AND token IN (");
let mut separated = builder.separated(", ");
for token in args.child_tokens {
for token in filtered_children {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
@@ -72,13 +72,25 @@ pub async fn bulk_add_subfolders_handler(
warn!("Parent folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if parent.is_none() {
return Err(CommonWebError::NotFound);
}
let parent = match parent {
Some(parent) => parent,
None => return Err(CommonWebError::NotFound),
};
// Drop any self-reference up front. A folder can't be its own parent;
// reporting it as "accepted" would be a lie since the SQL guard
// silently excludes it from the UPDATE.
let parent_str = parent.token.as_str();
let candidates: Vec<FolderToken> = request.subfolder_tokens
.iter()
.filter(|t| t.as_str() != parent_str)
.cloned()
.collect();
let accepted = filter_existing_owned_folder_tokens(FilterExistingOwnedFolderTokensArgs {
candidate_tokens: &request.subfolder_tokens,
candidate_tokens: &candidates,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
@@ -100,9 +112,6 @@ pub async fn bulk_add_subfolders_handler(
})?;
}
// Note: `accepted` may include the parent itself; the SQL guard excludes
// it from the UPDATE. We still report it as "accepted" for client
// simplicity — no-op tokens are normal in idempotent bulk endpoints.
Ok(Json(BulkAddSubfoldersSuccessResponse {
success: true,
accepted_subfolder_tokens: accepted,