security(nextcloud): chroot-aware display paths + recent race fix

strip_chroot_prefix replaces the hardcoded "Personal/" strip
    in NC trashbin PROPFIND, OCS unified search, and REPORT
    (favorites + search). Handles composed chroots, drops
    cross-chroot items instead of surfacing malformed paths, and
    fixes the leading-slash mismatch (FolderDto path has '/', DB
    paths don't) that silently dropped every NC trashbin item
    post-D3. OCS keeps a first-segment fallback (results
    legitimately span drives, no single chroot).

    uploads_handler switches to nc_to_internal_path(chroot, …)
    for the two remaining hardcoded "Personal/" sites, closing
    the D1 TODO markers.

    RecentService::record_item_access is split from a new
    record_item_access_internal (no authz) used by
    RecentRecordingHook. Round 1's authz.require widened the
    tokio::spawn race past tests/api/recent.hurl step 7; the
    internal path skips the redundant Read gate — upstream
    _with_perms already enforced it.

    Tests: 8 unit tests pin strip_chroot_prefix (leading slash,
    composed chroots, sibling-leak rejection, partial-prefix,
    empty-chroot). drives_membership.hurl step 21b/22b cover
    Editor upload → 201 / Viewer upload → 404 fresh + overwrite
    with fixture cleanup at 30c. test_nc_move_copy_delete_trash
    K1 pins the actual original-location value.
This commit is contained in:
Edouard Vanbelle
2026-07-05 22:52:26 +02:00
parent 0342bae300
commit 1786fe4111
8 changed files with 446 additions and 54 deletions
+13 -11
View File
@@ -422,13 +422,17 @@ pub async fn handle_search(
let mut entries: Vec<serde_json::Value> = Vec::new();
// Map file results
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
// caller's default-drive root folder name from `drives.root_folder_id`
// instead. Correct for D0-provisioned default drives; secondary
// drives keep their original root name.
// Map file results.
//
// `strip_drive_root_segment` handles both default and secondary
// drives — post-D0 the first path segment is the drive's root
// folder name (`"Personal"` for D0-provisioned defaults, the
// original sibling-root name for M2 backfilled secondaries).
// Read-scope is upstream in `state.applications.search_service`;
// this handler only formats display paths.
for file in &results.files {
let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path);
let display_path =
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path);
let display_path = format!("/{}", display_path);
let numeric_id = file_id_map.get(&file.id).copied();
@@ -452,12 +456,10 @@ pub async fn handle_search(
}));
}
// Map folder results — same TODO(D1) as above.
// Map folder results — same drive-agnostic strip as above.
for folder in &results.folders {
let display_path = folder
.path
.strip_prefix("Personal/")
.unwrap_or(&folder.path);
let display_path =
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&folder.path);
let display_path = format!("/{}", display_path);
entries.push(json!({