sjr666666 6ce385b963
fix(uploads): stop Windows replacement uploads from being skipped by the multi-link guard (#6821)
* fix(uploads): stop Windows replacement uploads from being skipped by the multi-link guard

NTFS never decrements st_nlink when one of a file's names is removed, so
every upload published through os.link(staging, dest) + unlink keeps
reading st_nlink == 2 on Windows. The non-exclusive replacement guards
then reject that destination forever, and DeerFlowClient.upload_files
turns the UnsafeUploadPathError into a log line - a replacement upload
is silently skipped (Closes #6820).

- Add _replacement_destination_is_hardlinked: POSIX-authoritative
  st_nlink > 1, skipped on Windows, used only on the not exclusive
  replacement path. The exclusive (first-claim) path keeps the strict
  count check, as does the POSIX O_NOFOLLOW branch.
- Decide "published alias" in cleanup_stale_upload_staging_files by
  inode identity against siblings (st_dev/st_ino via os.lstat) instead
  of the link count; DirEntry.stat reports zeros for both on Windows,
  matching the existing _staging_entry_stat note.
- Guard the POSIX-only st_nlink assertions in the affected tests and
  mark the three inbound-attachment mode-bit tests POSIX-only, mirroring
  the existing skipif in the same class.

* fix(uploads): restore the exclusive post-open link guard and harden the staging sweep

Address the three review findings on 5353715:

- Restore the strict post-open st_nlink != 1 check for exclusive=True
  on the no-O_NOFOLLOW (Windows fallback) branch, which the previous
  commit dropped there against what the PR description claimed. Only
  the non-exclusive replacement path defers to the Windows-aware guard.
- Never match a degenerate st_ino == 0 in the sibling-inode sweep: on
  FAT/exFAT or some SMB volumes every entry would look like a published
  alias and in-flight uploads would be deleted at any age. Such volumes
  fall back to the min_age guard only.
- Build one {name: stat} map per uploads directory before the entry
  loop so the sweep scans each directory once instead of rescanning it
  for every staging file (O(N) instead of O(N^2) lstat calls).

Tests: 407 passed / 27 skipped across test_uploads_manager.py,
test_uploads_router.py, test_upload_copy_publication.py,
test_upload_windows_staging_aliases.py, test_channel_file_attachments.py
(Windows 11, Python 3.13 venv). The three new regression tests fail on
5353715 and pass with this change.

* style(uploads): apply Ruff formatting

* fix(uploads): report staging directory scan failures

---------

Co-authored-by: sjr666666 <sjr666666@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 08:43:26 +08:00
..