diff --git a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs index cddc04be01..d97df2194c 100644 --- a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs +++ b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs @@ -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 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::::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(")"); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs index cd5aeda604..58ecdf2600 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/bulk_add_subfolders_handler.rs @@ -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 = 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,