Skip to content

Commit 09a8e09

Browse files
author
Binity Bot
committed
feat(secure): add secure delete functionality (v3.3.0)
1 parent 00c829a commit 09a8e09

10 files changed

Lines changed: 387 additions & 42 deletions

File tree

release_helper.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,28 @@
1313

1414
RELEASES = [
1515
{
16-
"tag": "v3.2.1",
17-
"prev": "v3.2.0",
18-
"name": "Binity v3.2.1",
19-
"body": """## 🛠️ UX Improvements (v3.2.1)
20-
Hotfix release improving the Auto-Updater user experience.
21-
22-
### improvements
23-
- **User Feedback**: "Check for updates" now displays an immediate "Checking..." notification.
24-
- **Dialogs**: "No updates found" and errors are now shown in a dialog box instead of a transient notification, ensuring you don't miss the result.
16+
"tag": "v3.3.0",
17+
"prev": "v3.2.1",
18+
"name": "Binity v3.3.0",
19+
"body": """## 🛡️ Secure Delete (Best Effort) & UX (v3.3.0)
20+
Major update introducing privacy-focused deletion features and enhanced settings.
21+
22+
### 🔥 New Features
23+
- **🛡️ Secure Delete**:
24+
- Added "Secure Delete" modes in Settings: **1-pass zeros** and **1-pass random data**.
25+
- **Best Effort**: Attempts to overwrite file content before deletion.
26+
- **Payload Protection**: Strictly wipes only files within `$Recycle.Bin` matching specific patterns (`$R...`), ensuring safety of other data.
27+
- **Feedback**: Detailed notifications about how many files were successfully overwritten and if any were locked.
28+
- **Improved UX**:
29+
- **Confirmation Dialogs**: Now clearly state which mode is active (Normal vs Secure) and warn about disk load.
30+
- **Warnings**: One-time warning when enabling secure mode about SSD wear and limitations.
31+
32+
### 🛠️ Improvements
33+
- **Tests**: Added unit tests for secure deletion logic and settings normalization.
34+
- **I18n**: Fully localized (RU/EN) for all new dialogs and menus.
2535
2636
### 📝 Notes
27-
- Release to ensure users have the best updater experience out-of-the-box.
37+
- **SSD Users**: Please note that due to hardware wear leveling, absolute secure deletion cannot be guaranteed on modern SSDs/NVMe drives without full disk encryption/sanitization. Binity does its best to overwrite data at the OS level.
2838
"""
2939
}
3040
]

src/core/i18n.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@
2323
"sound_off": "Без звука",
2424
"sound_windows": "Системный звук Windows",
2525
"sound_paper": "Сминание бумаги",
26+
"secure_delete": "Безвозвратное удаление",
27+
"secure_delete_off": "Обычная очистка (быстро)",
28+
"secure_delete_zero": "Secure Delete: 1-pass нулями",
29+
"secure_delete_random": "Secure Delete: 1-pass случайными данными",
30+
"secure_delete_load_note": "Повышает нагрузку на диск и замедляет очистку",
31+
"secure_delete_info_title": "Безвозвратное удаление",
32+
"secure_delete_info_message": "Режим повышает нагрузку на диск. Для SSD/NVMe абсолютная гарантия стирания не обеспечивается из-за wear leveling.",
33+
"secure_clear_started": "Запущена безопасная очистка. Возможна повышенная нагрузка на диск.",
34+
"secure_clear_success_message": "Безопасная очистка завершена: перезаписано {files} файлов ({size}).",
35+
"secure_clear_partial_message": "Часть файлов не удалось перезаписать ({failed}). Остальные элементы удалены стандартно.",
2636
"overflow_notify": "Уведомлять о переполнении",
2737
"theme_sync": "Синхронизировать тему Windows",
2838
"auto_check_updates": "Автопроверка обновлений",
@@ -50,6 +60,8 @@
5060
"exit": "Выход",
5161
"confirm_dialog_title": "Подтверждение очистки корзины",
5262
"confirm_dialog_message": "Удалить все элементы из корзины без возможности восстановления?",
63+
"confirm_dialog_message_secure_zero": "Выполнить безопасную очистку корзины (1-pass нулями)?\n\nЭто замедлит операцию и увеличит нагрузку на диск.",
64+
"confirm_dialog_message_secure_random": "Выполнить безопасную очистку корзины (1-pass случайными данными)?\n\nЭто самая тяжелая нагрузка на диск среди режимов очистки.",
5365
"confirm": "Очистить",
5466
"cancel": "Отмена",
5567
"about_title": "О программе",
@@ -86,6 +98,16 @@
8698
"sound_off": "No sound",
8799
"sound_windows": "Windows system sound",
88100
"sound_paper": "Paper crumple",
101+
"secure_delete": "Secure delete",
102+
"secure_delete_off": "Normal empty (fast)",
103+
"secure_delete_zero": "Secure Delete: 1-pass zeros",
104+
"secure_delete_random": "Secure Delete: 1-pass random data",
105+
"secure_delete_load_note": "Increases disk load and slows cleanup",
106+
"secure_delete_info_title": "Secure Delete",
107+
"secure_delete_info_message": "This mode increases disk load. On SSD/NVMe absolute wipe guarantees are not possible due to wear leveling.",
108+
"secure_clear_started": "Secure cleanup started. Disk activity may temporarily increase.",
109+
"secure_clear_success_message": "Secure cleanup finished: overwritten {files} files ({size}).",
110+
"secure_clear_partial_message": "Some files could not be overwritten ({failed}). Remaining items were removed normally.",
89111
"overflow_notify": "Notify when overloaded",
90112
"theme_sync": "Sync with Windows theme",
91113
"auto_check_updates": "Auto-check updates",
@@ -113,6 +135,8 @@
113135
"exit": "Exit",
114136
"confirm_dialog_title": "Confirm Empty Recycle Bin",
115137
"confirm_dialog_message": "Delete all items from Recycle Bin permanently?",
138+
"confirm_dialog_message_secure_zero": "Run secure recycle-bin cleanup (1-pass zeros)?\n\nThis is slower and increases disk load.",
139+
"confirm_dialog_message_secure_random": "Run secure recycle-bin cleanup (1-pass random data)?\n\nThis is the heaviest disk load mode.",
116140
"confirm": "Empty",
117141
"cancel": "Cancel",
118142
"about_title": "About",

src/core/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"overflow_notify_enabled": True,
1818
"overflow_notify_threshold_gb": 15,
1919
"theme_sync": True,
20+
"secure_delete_mode": "off",
21+
"secure_delete_info_ack": False,
2022
"auto_check_updates": True,
2123
"last_update_check": "",
2224
"skipped_update_version": "",
@@ -86,6 +88,12 @@ def _normalize(self) -> None:
8688

8789
self.values["theme_sync"] = bool(self.values.get("theme_sync", True))
8890

91+
secure_mode = str(self.values.get("secure_delete_mode", "off")).lower()
92+
if secure_mode not in ("off", "zero", "random"):
93+
secure_mode = "off"
94+
self.values["secure_delete_mode"] = secure_mode
95+
self.values["secure_delete_info_ack"] = bool(self.values.get("secure_delete_info_ack", False))
96+
8997
self.values["auto_check_updates"] = bool(self.values.get("auto_check_updates", True))
9098

9199
last_update_check = str(self.values.get("last_update_check", "") or "").strip()
@@ -200,6 +208,15 @@ def theme_sync(self) -> bool:
200208
def auto_check_updates(self) -> bool:
201209
return bool(self.get("auto_check_updates", True))
202210

211+
@property
212+
def secure_delete_mode(self) -> str:
213+
mode = str(self.get("secure_delete_mode", "off")).lower()
214+
return mode if mode in ("off", "zero", "random") else "off"
215+
216+
@property
217+
def secure_delete_info_ack(self) -> bool:
218+
return bool(self.get("secure_delete_info_ack", False))
219+
203220
@property
204221
def last_update_check(self) -> str:
205222
return str(self.get("last_update_check", "") or "")

src/services/recycle_bin.py

Lines changed: 171 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1-
from __future__ import annotations
1+
from __future__ import annotations
22

33
import ctypes
4+
import os
45
import subprocess
56
from dataclasses import dataclass
7+
from pathlib import Path
68

79

810
SHERB_NOCONFIRMATION = 0x00000001
911
SHERB_NOPROGRESSUI = 0x00000002
1012
SHERB_NOSOUND = 0x00000004
1113

14+
SECURE_DELETE_OFF = "off"
15+
SECURE_DELETE_ZERO = "zero"
16+
SECURE_DELETE_RANDOM = "random"
17+
SECURE_DELETE_MODES = (SECURE_DELETE_OFF, SECURE_DELETE_ZERO, SECURE_DELETE_RANDOM)
18+
19+
_WIPE_CHUNK_SIZE = 1024 * 1024
20+
1221

1322
class SHQUERYRBINFO(ctypes.Structure):
1423
_fields_ = [
@@ -18,18 +27,27 @@ class SHQUERYRBINFO(ctypes.Structure):
1827
]
1928

2029

21-
@dataclass
30+
@dataclass(slots=True)
2231
class RecycleBinInfo:
2332
size_bytes: int
2433
items: int
2534

2635

36+
@dataclass(slots=True)
37+
class BinClearResult:
38+
success: bool
39+
secure_mode: str
40+
wiped_files: int = 0
41+
wiped_bytes: int = 0
42+
wipe_failures: int = 0
43+
44+
2745
class RecycleBinService:
2846
SIZE_THRESHOLDS_BYTES = (
29-
256 * 1024 * 1024, # 256 MB
30-
int(1.5 * 1024**3), # 1.5 GB
31-
4 * 1024**3, # 4 GB
32-
8 * 1024**3, # 8 GB
47+
256 * 1024 * 1024, # 256 MB
48+
int(1.5 * 1024**3), # 1.5 GB
49+
4 * 1024**3, # 4 GB
50+
8 * 1024**3, # 8 GB
3351
)
3452
ITEM_THRESHOLDS = (
3553
25,
@@ -79,14 +97,160 @@ def get_level(cls) -> int:
7997
return cls.level_from_metrics(info.size_bytes, info.items)
8098

8199
@staticmethod
82-
def empty_bin() -> bool:
100+
def _normalize_secure_mode(mode: str) -> str:
101+
candidate = str(mode or SECURE_DELETE_OFF).lower()
102+
return candidate if candidate in SECURE_DELETE_MODES else SECURE_DELETE_OFF
103+
104+
@staticmethod
105+
def _is_safe_recycle_payload_path(path: Path) -> bool:
106+
text = str(path).replace("/", "\\").lower()
107+
if "\\$recycle.bin\\" not in text:
108+
return False
109+
suffix = text.split("\\$recycle.bin\\", 1)[1]
110+
parts = [part for part in suffix.split("\\") if part]
111+
if len(parts) < 2:
112+
return False
113+
# Expected pattern: <sid>\\$Rxxxxx[\\...]
114+
return parts[1].startswith("$r")
115+
116+
@staticmethod
117+
def _iter_drive_letters():
118+
if os.name != "nt":
119+
return
120+
121+
try:
122+
mask = int(ctypes.windll.kernel32.GetLogicalDrives())
123+
if mask <= 0:
124+
raise RuntimeError("no drives mask")
125+
for index in range(26):
126+
if mask & (1 << index):
127+
yield chr(ord("A") + index)
128+
return
129+
except Exception:
130+
pass
131+
132+
for index in range(26):
133+
yield chr(ord("A") + index)
134+
135+
@classmethod
136+
def _iter_wipe_targets(cls):
137+
if os.name != "nt":
138+
return
139+
140+
for letter in cls._iter_drive_letters() or []:
141+
recycle_root = Path(f"{letter}:\\$Recycle.Bin")
142+
if not recycle_root.exists() or not recycle_root.is_dir():
143+
continue
144+
145+
try:
146+
sid_dirs = recycle_root.iterdir()
147+
except OSError:
148+
continue
149+
150+
for sid_dir in sid_dirs:
151+
if not sid_dir.is_dir():
152+
continue
153+
154+
try:
155+
entries = sid_dir.iterdir()
156+
except OSError:
157+
continue
158+
159+
for entry in entries:
160+
if not entry.name.lower().startswith("$r"):
161+
continue
162+
if entry.is_symlink():
163+
continue
164+
165+
if entry.is_file():
166+
if cls._is_safe_recycle_payload_path(entry):
167+
yield entry
168+
continue
169+
170+
if not entry.is_dir():
171+
continue
172+
173+
try:
174+
nested_items = entry.rglob("*")
175+
except OSError:
176+
continue
177+
178+
for nested in nested_items:
179+
if nested.is_symlink() or not nested.is_file():
180+
continue
181+
if cls._is_safe_recycle_payload_path(nested):
182+
yield nested
183+
184+
@staticmethod
185+
def _wipe_file(path: Path, mode: str) -> int:
186+
try:
187+
size = int(path.stat().st_size)
188+
except OSError:
189+
return 0
190+
191+
if size <= 0:
192+
return 0
193+
194+
zero_chunk = b"\x00" * _WIPE_CHUNK_SIZE if mode == SECURE_DELETE_ZERO else b""
195+
196+
with open(path, "r+b") as fh:
197+
remaining = size
198+
while remaining > 0:
199+
chunk_size = _WIPE_CHUNK_SIZE if remaining >= _WIPE_CHUNK_SIZE else remaining
200+
if mode == SECURE_DELETE_ZERO:
201+
fh.write(zero_chunk[:chunk_size])
202+
else:
203+
fh.write(os.urandom(chunk_size))
204+
remaining -= chunk_size
205+
fh.flush()
206+
os.fsync(fh.fileno())
207+
208+
return size
209+
210+
@classmethod
211+
def _best_effort_secure_wipe(cls, mode: str) -> tuple[int, int, int]:
212+
wiped_files = 0
213+
wiped_bytes = 0
214+
wipe_failures = 0
215+
216+
for target in cls._iter_wipe_targets() or []:
217+
try:
218+
bytes_written = cls._wipe_file(target, mode)
219+
wiped_files += 1
220+
wiped_bytes += bytes_written
221+
except Exception:
222+
wipe_failures += 1
223+
224+
return wiped_files, wiped_bytes, wipe_failures
225+
226+
@staticmethod
227+
def _empty_bin_shell() -> bool:
83228
try:
84229
flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND
85230
result = ctypes.windll.shell32.SHEmptyRecycleBinW(None, None, flags)
86231
return result == 0
87232
except Exception:
88233
return False
89234

235+
@classmethod
236+
def empty_bin(cls, secure_mode: str = SECURE_DELETE_OFF) -> BinClearResult:
237+
mode = cls._normalize_secure_mode(secure_mode)
238+
239+
wiped_files = 0
240+
wiped_bytes = 0
241+
wipe_failures = 0
242+
if mode != SECURE_DELETE_OFF:
243+
wiped_files, wiped_bytes, wipe_failures = cls._best_effort_secure_wipe(mode)
244+
245+
success = cls._empty_bin_shell()
246+
return BinClearResult(
247+
success=success,
248+
secure_mode=mode,
249+
wiped_files=wiped_files,
250+
wiped_bytes=wiped_bytes,
251+
wipe_failures=wipe_failures,
252+
)
253+
90254
@staticmethod
91255
def open_bin() -> bool:
92256
try:

src/ui/dialogs/confirm_dialog.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77

88

99
class ConfirmDialog(QDialog):
10-
def __init__(self, i18n: I18n, parent=None) -> None:
10+
def __init__(self, i18n: I18n, message_override: str | None = None, parent=None) -> None:
1111
super().__init__(parent)
1212
self.i18n = i18n
13+
self._message_override = message_override
1314

1415
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
1516
self.setModal(True)
@@ -63,6 +64,6 @@ def refresh_texts(self) -> None:
6364
title = self.i18n.tr("confirm_dialog_title")
6465
self.setWindowTitle(title)
6566
self.title_label.setText(title)
66-
self.message_label.setText(self.i18n.tr("confirm_dialog_message"))
67+
self.message_label.setText(self._message_override or self.i18n.tr("confirm_dialog_message"))
6768
self.cancel_btn.setText(self.i18n.tr("cancel"))
6869
self.confirm_btn.setText(self.i18n.tr("confirm"))

0 commit comments

Comments
 (0)