safekeep 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {safekeep-0.5.0/safekeep.egg-info → safekeep-0.5.2}/PKG-INFO +2 -2
  2. {safekeep-0.5.0 → safekeep-0.5.2}/README.md +1 -1
  3. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/__init__.py +1 -1
  4. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/cli.py +21 -2
  5. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/copier.py +31 -1
  6. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/daemon.py +16 -5
  7. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/platform.py +42 -2
  8. {safekeep-0.5.0 → safekeep-0.5.2/safekeep.egg-info}/PKG-INFO +2 -2
  9. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_cli.py +36 -0
  10. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_copier.py +67 -0
  11. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_daemon.py +4 -0
  12. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_platform.py +87 -0
  13. {safekeep-0.5.0 → safekeep-0.5.2}/LICENSE +0 -0
  14. {safekeep-0.5.0 → safekeep-0.5.2}/pyproject.toml +0 -0
  15. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/config.py +0 -0
  16. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/matcher.py +0 -0
  17. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep/volumes.py +0 -0
  18. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep.egg-info/SOURCES.txt +0 -0
  19. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep.egg-info/dependency_links.txt +0 -0
  20. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep.egg-info/entry_points.txt +0 -0
  21. {safekeep-0.5.0 → safekeep-0.5.2}/safekeep.egg-info/top_level.txt +0 -0
  22. {safekeep-0.5.0 → safekeep-0.5.2}/setup.cfg +0 -0
  23. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_config.py +0 -0
  24. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_matcher.py +0 -0
  25. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_trend.py +0 -0
  26. {safekeep-0.5.0 → safekeep-0.5.2}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/foxhound87/safekeep
@@ -190,7 +190,7 @@ safekeep <command> [--config PATH] [-v]
190
190
  python3 -m unittest discover -s tests
191
191
  ```
192
192
 
193
- Stdlib (`unittest`) suite, zero dependencies: 206 tests covering the matcher,
193
+ Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
194
194
  config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
195
195
  wrapper and the POSIX portability layer (platform helper, per-OS fswatch
196
196
  monitor, systemd template).
@@ -165,7 +165,7 @@ safekeep <command> [--config PATH] [-v]
165
165
  python3 -m unittest discover -s tests
166
166
  ```
167
167
 
168
- Stdlib (`unittest`) suite, zero dependencies: 206 tests covering the matcher,
168
+ Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
169
169
  config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
170
170
  wrapper and the POSIX portability layer (platform helper, per-OS fswatch
171
171
  monitor, systemd template).
@@ -3,4 +3,4 @@ import logging
3
3
 
4
4
  logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
5
 
6
- __version__ = '0.5.0'
6
+ __version__ = '0.5.2'
@@ -19,6 +19,7 @@ from safekeep.daemon import Daemon, setup_logging
19
19
  from safekeep.platform import (
20
20
  INOTIFY_MIN_WATCHES,
21
21
  LOG_PATH,
22
+ daemon_exe,
22
23
  fswatch_monitor,
23
24
  inotify_limit,
24
25
  install_hint,
@@ -65,8 +66,8 @@ def build_parser():
65
66
  doc = sub.add_parser('doctor', parents=[common],
66
67
  help='diagnostica: config, fswatch + monitor di piattaforma, '
67
68
  'python, errori di permesso recenti nel log, TCC/plist '
68
- '(macOS), inotify/systemd/linger (Linux) — exit ≠ 0 se '
69
- 'un check fatale fallisce')
69
+ 'ed exe del daemon (macOS), inotify/systemd/linger (Linux) '
70
+ '— exit ≠ 0 se un check fatale fallisce')
70
71
  doc.add_argument('--json', action='store_true',
71
72
  help='stessi check, stesso exit code, ma un documento JSON su '
72
73
  'stdout (SPEC.md §9.1)')
@@ -369,6 +370,24 @@ def cmd_doctor(args):
369
370
  'senza login → loginctl enable-linger $USER — '
370
371
  'warning non fatale')
371
372
 
373
+ # exe del daemon ancora su disco (SPEC.md §8.4/§14.4, 0.5.1): un processo
374
+ # può girare su un eseguibile cancellato da un upgrade sotto un daemon vivo
375
+ # → TCC (Transparency, Consent and Control) non lo identifica più e ogni
376
+ # copia fallisce con EPERM. Guarda il PROCESSO, non il plist (funziona
377
+ # anche col plist renderizzato col vecchio default). Non fatale: sintomo
378
+ # con rimedio manuale di una riga, e doctor deve girare anche in quello stato.
379
+ if is_darwin():
380
+ exe = daemon_exe()
381
+ if exe is None:
382
+ out(True, 'exe del daemon: processo non determinabile — check saltato')
383
+ elif os.path.exists(exe):
384
+ out(True, f'exe del daemon: {exe} presente su disco')
385
+ else:
386
+ out(False, f'exe del daemon: {exe} NON esiste su disco — il processo '
387
+ 'gira su un eseguibile cancellato (upgrade sotto un daemon '
388
+ 'vivo, SPEC §8.4) → launchctl kickstart -k '
389
+ 'gui/$(id -u)/com.safekeep.agent — warning non fatale')
390
+
372
391
  code = 1 if fatal else 0
373
392
  if args.json:
374
393
  count, last = recent if recent is not None else (0, None)
@@ -20,6 +20,36 @@ MTIME_TOLERANCE = 1.0 # secondi: tolleranza mtime per volumi a risoluzione
20
20
  TMP_INFIX = '.safekeep.tmp.'
21
21
  # errori che dicono "questa dest non è usabile adesso" → pending + backoff (§8.3)
22
22
  MOUNT_ERRNOS = (errno.ENODEV, errno.EBUSY, errno.ENOSPC)
23
+ # throttle delle copie fallite (SPEC.md §10.1, 0.5.1): stessa (path, errno) →
24
+ # una riga + `— ripetuto N volte` ogni THROTTLE_INTERVAL, non una riga a fallimento
25
+ THROTTLE_INTERVAL = 300.0 # secondi
26
+ _copy_errors = {} # (src, errno) → [ultimo log monotonic, non loggati]
27
+
28
+
29
+ def log_copy_error(src, dst, e, now=None):
30
+ """`copia fallita src → dst: e` con dedup per **(path, errno)** (SPEC.md §10.1).
31
+
32
+ Prima occorrenza: riga storica invariata. Poi al massimo una riga ogni
33
+ `THROTTLE_INTERVAL` per chiave, col suffisso `— ripetuto N volte` (N =
34
+ fallimenti non loggati da quell'ultima riga). `recent_permission_errors`
35
+ (§14.4) somma N+1 su queste righe: `count_24h` resta il conteggio vero,
36
+ sono solo le righe sul file a essere di meno. I conteggi in memoria
37
+ (`stats['errati']`, `sync-once`) non leggono da qui.
38
+ """
39
+ if now is None:
40
+ now = time.monotonic()
41
+ key = (src, e.errno)
42
+ entry = _copy_errors.get(key)
43
+ if entry is None or now - entry[0] >= THROTTLE_INTERVAL:
44
+ pending = 0 if entry is None else entry[1]
45
+ if pending:
46
+ log.error('copia fallita %s → %s: %s — ripetuto %d volte',
47
+ src, dst, e, pending)
48
+ else:
49
+ log.error('copia fallita %s → %s: %s', src, dst, e)
50
+ _copy_errors[key] = [now, 0]
51
+ else:
52
+ entry[1] += 1
23
53
 
24
54
 
25
55
  def _tmp_path(dst):
@@ -215,7 +245,7 @@ def reconcile_project(source_root, dest_root, matcher, layout, dest_path_fn,
215
245
  log.error('errore di mount su %s → %s: %s: la dest va in pending',
216
246
  src, dst, e)
217
247
  return True
218
- log.error('copia fallita %s → %s: %s', src, dst, e)
248
+ log_copy_error(src, dst, e)
219
249
  return False
220
250
 
221
251
  for dirpath, dirnames, filenames in os.walk(root):
@@ -25,7 +25,14 @@ from .config import (
25
25
  source_root_for,
26
26
  validate_dests,
27
27
  )
28
- from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
28
+ from .copier import (
29
+ MOUNT_ERRNOS,
30
+ copy_one,
31
+ log_copy_error,
32
+ needs_copy,
33
+ prune_project,
34
+ reconcile_project,
35
+ )
29
36
  from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
30
37
  from .platform import LOG_PATH, fswatch_monitor, init_system
31
38
  from .volumes import backoff_schedule, dest_state, partition_dests
@@ -411,9 +418,13 @@ class Daemon:
411
418
  # --- fswatch ---------------------------------------------------------
412
419
 
413
420
  def fswatch_argv(self):
414
- """`fswatch -0 -m <monitor> -r -l 1.0 -e <regex> -- <roots>` (§6.4, §14.2):
415
- il monitor è dichiarato per piattaforma, mai il default implicito."""
416
- argv = ['fswatch', '-0', '-m', fswatch_monitor(), '-r', '-l', '1.0']
421
+ """`fswatch -0 -m <monitor> -r -l 1.0 -E -e <regex> -- <roots>` (§6.4, §14.2):
422
+ il monitor è dichiarato per piattaforma, mai il default implicito;
423
+ `-E` = regex ERE (Extended Regular Expressions): senza, fswatch legge le
424
+ `-e` come BRE (Basic Regular Expressions) dove `(`/`|` sono letterali e
425
+ NESSUNA esclusione filtra (con un `.sync` in `$HOME` i path di `Library`
426
+ entrano nel matcher e il daemon satura in `walk_dir`)."""
427
+ argv = ['fswatch', '-0', '-m', fswatch_monitor(), '-r', '-l', '1.0', '-E']
417
428
  for regex in self.exclude_regexes():
418
429
  argv += ['-e', regex]
419
430
  return argv + ['--'] + self.discovery_roots()[0]
@@ -629,7 +640,7 @@ class Daemon:
629
640
  src, dst, e)
630
641
  self.mark_pending(project, dest)
631
642
  else:
632
- log.error('copia fallita %s → %s: %s', src, dst, e)
643
+ log_copy_error(src, dst, e)
633
644
 
634
645
  def walk_dir(self, dirpath, project):
635
646
  """Mini-walk: i figli di una directory appena creata potrebbero non
@@ -15,6 +15,7 @@ import os
15
15
  import platform as _platform
16
16
  import re
17
17
  import shutil
18
+ import subprocess
18
19
  import sys
19
20
  from datetime import datetime
20
21
 
@@ -26,6 +27,9 @@ SYSTEMD_RUN_PATH = '/run/systemd/system' # assente su WSL1/systemd of
26
27
  # EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
27
28
  # + testo del kernel (SPEC.md §14.4)
28
29
  PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
30
+ # suffisso del throttle (SPEC.md §10.1, 0.5.1): la riga rappresenta se stessa
31
+ # + i N fallimenti compressi → count_24h la conta come N+1, non 1 (onesto)
32
+ REPEAT_RE = re.compile(r'ripetuto (\d+) volte')
29
33
 
30
34
 
31
35
  def is_darwin():
@@ -75,7 +79,10 @@ def recent_permission_errors(path=None, now=None, window=86400):
75
79
  (SPEC.md §14.4: check non fatale, finestra 24h — segue l'incidente EPERM).
76
80
 
77
81
  Il timestamp è il prefisso `%(asctime)s` della riga (`YYYY-MM-DD HH:MM:SS`,
78
- prime 19 posizioni). **Limite dichiarato**: una riga senza timestamp
82
+ prime 19 posizioni). Le righe col suffisso del throttle `— ripetuto N volte`
83
+ (§10.1) valgono **N + 1**: la riga stessa + i N fallimenti compressi, così
84
+ il conteggio resta il numero vero di errori anche con le righe throttolate.
85
+ **Limite dichiarato**: una riga senza timestamp
79
86
  parsificabile o fuori dalla finestra (clock skew incluso) non viene contata —
80
87
  non essendo sicuri che sia recente, non la contiamo: il conteggio è un minimo."""
81
88
  if path is None:
@@ -95,7 +102,8 @@ def recent_permission_errors(path=None, now=None, window=86400):
95
102
  age = (now - ts).total_seconds()
96
103
  if not 0 <= age <= window:
97
104
  continue # troppo vecchio (o nel futuro: clock skew)
98
- count += 1
105
+ rep = REPEAT_RE.search(line)
106
+ count += 1 + (int(rep.group(1)) if rep else 0)
99
107
  if last is None or ts > last:
100
108
  last = ts
101
109
  except OSError:
@@ -103,6 +111,38 @@ def recent_permission_errors(path=None, now=None, window=86400):
103
111
  return count, last
104
112
 
105
113
 
114
+ DAEMON_LABEL = 'com.safekeep.agent' # label launchd dell'agent (§8.1)
115
+
116
+
117
+ def daemon_exe(label=DAEMON_LABEL):
118
+ """→ eseguibile assoluto del processo launchd `label`, o None se non
119
+ determinabile (SPEC.md §8.4/§14.4, 0.5.1: job non caricato, processo non
120
+ trovato, `launchctl`/`ps` assenti o falliti, non-Darwin). Solo lettura.
121
+
122
+ Serve al check `doctor` (§14.4): un processo può girare su un eseguibile
123
+ **cancellato** da un upgrade sotto un daemon vivo (brew upgrade → path
124
+ Cellar sparito → TCC non identifica il processo → EPERM su ogni copia).
125
+ """
126
+ if not is_darwin():
127
+ return None
128
+ try:
129
+ job = subprocess.run(
130
+ ['launchctl', 'print', f'gui/{os.getuid()}/{label}'],
131
+ capture_output=True, text=True, timeout=10)
132
+ except (OSError, subprocess.SubprocessError):
133
+ return None
134
+ pid = re.search(r'^\s*pid = (\d+)', job.stdout, re.M)
135
+ if job.returncode != 0 or pid is None:
136
+ return None # job non caricato o senza processo
137
+ try:
138
+ ps = subprocess.run(['ps', '-p', pid.group(1), '-o', 'comm='],
139
+ capture_output=True, text=True, timeout=10)
140
+ except (OSError, subprocess.SubprocessError):
141
+ return None
142
+ exe = ps.stdout.strip().splitlines()[0].strip() if ps.stdout.strip() else ''
143
+ return exe if ps.returncode == 0 and exe else None
144
+
145
+
106
146
  def linger_enabled(user=None, linger_dir=None):
107
147
  """`True`/`False` se l'utente è in `/var/lib/systemd/linger/`, None se la
108
148
  directory non c'è (niente systemd → non determinabile, SPEC.md §14.4).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/foxhound87/safekeep
@@ -190,7 +190,7 @@ safekeep <command> [--config PATH] [-v]
190
190
  python3 -m unittest discover -s tests
191
191
  ```
192
192
 
193
- Stdlib (`unittest`) suite, zero dependencies: 206 tests covering the matcher,
193
+ Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
194
194
  config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
195
195
  wrapper and the POSIX portability layer (platform helper, per-OS fswatch
196
196
  monitor, systemd template).
@@ -147,6 +147,42 @@ class DoctorTest(CliTestCase):
147
147
  self.assertEqual(r.returncode, 0, r.stdout)
148
148
 
149
149
 
150
+ class DoctorExeDaemonTest(CliTestCase):
151
+ """SPEC.md §8.4/§14.4 (0.5.1): exe del processo launchd su disco —
152
+ esistente ✔, cancellato ✗ **non fatale**, non determinabile → saltato."""
153
+
154
+ def doctor_darwin(self, exe):
155
+ args = argparse.Namespace(config=self.cfg, v=False, json=False)
156
+ buf = io.StringIO()
157
+ with mock.patch.object(cli, 'is_darwin', return_value=True), \
158
+ mock.patch.object(cli, 'daemon_exe', return_value=exe), \
159
+ contextlib.redirect_stdout(buf):
160
+ code = cli.cmd_doctor(args)
161
+ line = next((l for l in buf.getvalue().splitlines()
162
+ if 'exe del daemon' in l), '')
163
+ return code, buf.getvalue(), line
164
+
165
+ def test_exe_esistente_verde(self):
166
+ code, _, line = self.doctor_darwin(sys.executable)
167
+ self.assertTrue(line.startswith('✔'), line)
168
+ self.assertIn('presente su disco', line)
169
+ self.assertEqual(code, 0)
170
+
171
+ def test_exe_cancellato_warning_non_fatale(self):
172
+ exe = os.path.join(self.tmp, 'Cellar', 'python@3.14', '3.14.6', 'Python')
173
+ code, _, line = self.doctor_darwin(exe) # non creato: inesistente
174
+ self.assertTrue(line.startswith('✗'), line)
175
+ for needle in ('NON esiste su disco', 'kickstart', 'warning non fatale'):
176
+ self.assertIn(needle, line)
177
+ self.assertEqual(code, 0, 'sintomo con rimedio manuale: NON fatale')
178
+
179
+ def test_non_determinabile_check_saltato(self):
180
+ code, _, line = self.doctor_darwin(None)
181
+ self.assertTrue(line.startswith('✔'), line)
182
+ self.assertIn('check saltato', line)
183
+ self.assertEqual(code, 0)
184
+
185
+
150
186
  class AutoDiscoveryCliTest(CliTestCase):
151
187
  """Config SENZA `source:` → modalità auto-discovery (SPEC.md §6)."""
152
188
 
@@ -296,6 +296,27 @@ class ErroriPerFileTest(CopierTestCase):
296
296
  self.assertFalse(os.path.exists(self.dst_root),
297
297
  'il walk si ferma: niente scritture a metà')
298
298
 
299
+ def test_reconcile_ripetuti_stessi_errori_pochissime_righe(self):
300
+ """SPEC.md §10.1 (0.5.1): 5 reconcile con gli stessi 3 file negati
301
+ (EPERM) → 3 righe di log (una per (path, errno)), non 15. I conteggi
302
+ in memoria restano intatti: 3 errori per passata."""
303
+ copier._copy_errors.clear()
304
+ self.addCleanup(copier._copy_errors.clear)
305
+
306
+ def boom(*args, **kwargs):
307
+ raise OSError(1, 'Operation not permitted')
308
+
309
+ with mock.patch.object(copier, 'copy_one', new=boom), \
310
+ mock.patch.object(copier.log, 'error') as errlog:
311
+ for _ in range(5):
312
+ stats = {}
313
+ reconcile_project(stats=stats, **self.kwargs)
314
+ self.assertEqual(stats['errati'], 3,
315
+ 'il conteggio non passa dal log')
316
+ self.assertEqual(errlog.call_count, 3,
317
+ 'una riga per (path, errno), le 4 passate successive '
318
+ 'rientrano nell\'intervallo di throttle')
319
+
299
320
 
300
321
  class PruneTest(CopierTestCase):
301
322
  """`sync-once --prune`: cancellazione solo certa e solo sotto il prefisso."""
@@ -332,5 +353,51 @@ class PruneTest(CopierTestCase):
332
353
  self.assertTrue(os.path.exists(os.path.join(self.dst_root, 'proj/old.txt')))
333
354
 
334
355
 
356
+ class ThrottleLogTest(CopierTestCase):
357
+ """SPEC.md §10.1 (0.5.1): stessa (path, errno) → ≤1 riga ogni
358
+ THROTTLE_INTERVAL con `— ripetuto N volte`, non una riga a fallimento."""
359
+
360
+ def setUp(self):
361
+ super().setUp()
362
+ copier._copy_errors.clear()
363
+ self.addCleanup(copier._copy_errors.clear)
364
+
365
+ @staticmethod
366
+ def err(no=1):
367
+ return OSError(no, 'Operation not permitted')
368
+
369
+ def test_50_failimenti_stesso_path_una_sola_riga(self):
370
+ with self.assertLogs(LOGGER, 'ERROR') as cm:
371
+ for _ in range(50):
372
+ copier.log_copy_error('/s/a', '/d/a', self.err(), now=1000.0)
373
+ self.assertEqual(len(cm.output), 1, cm.output)
374
+ self.assertNotIn('ripetuto', cm.output[0], 'nessun suffisso sulla 1ª riga')
375
+
376
+ def test_dopo_intervallo_una_riga_con_il_conteggio(self):
377
+ now = 1000.0
378
+ copier.log_copy_error('/s/a', '/d/a', self.err(), now=now)
379
+ with mock.patch.object(copier.log, 'error') as errlog: # 9 dentro la finestra
380
+ for _ in range(9):
381
+ copier.log_copy_error('/s/a', '/d/a', self.err(), now=now)
382
+ errlog.assert_not_called()
383
+ with self.assertLogs(LOGGER, 'ERROR') as cm: # finestra scaduta
384
+ copier.log_copy_error('/s/a', '/d/a', self.err(),
385
+ now=now + copier.THROTTLE_INTERVAL + 1)
386
+ for _ in range(5): # poi di nuovo silenzio
387
+ copier.log_copy_error('/s/a', '/d/a', self.err(),
388
+ now=now + copier.THROTTLE_INTERVAL + 1)
389
+ self.assertEqual(len(cm.output), 1, cm.output)
390
+ self.assertIn('ripetuto 9 volte', cm.output[0])
391
+
392
+ def test_chiavi_distinte_indipendenti(self):
393
+ now = 1000.0
394
+ with self.assertLogs(LOGGER, 'ERROR') as cm:
395
+ copier.log_copy_error('/s/a', '/d/a', self.err(1), now=now)
396
+ copier.log_copy_error('/s/a', '/d/a', self.err(1), now=now)
397
+ copier.log_copy_error('/s/b', '/d/b', self.err(1), now=now)
398
+ copier.log_copy_error('/s/a', '/d/a', self.err(13), now=now)
399
+ self.assertEqual(len(cm.output), 3, cm.output) # (a,1) una + b + (a,13)
400
+
401
+
335
402
  if __name__ == '__main__':
336
403
  unittest.main()
@@ -433,6 +433,10 @@ class FswatchArgvTest(unittest.TestCase):
433
433
  self.assertEqual(argv[:3], ['fswatch', '-0', '-m'])
434
434
  self.assertIn(argv[3], ('fsevents_monitor', 'inotify_monitor'))
435
435
  self.assertEqual(argv[4:7], ['-r', '-l', '1.0'])
436
+ # `-E` obbligatorio: fswatch legge le `-e` come BRE (gruppi/alternanze
437
+ # letterali) e le esclusioni non filtrerebbero più nulla
438
+ self.assertIn('-E', argv)
439
+ self.assertLess(argv.index('-E'), argv.index('-e') if '-e' in argv else len(argv))
436
440
  regexes = [argv[i + 1] for i, a in enumerate(argv) if a == '-e']
437
441
  self.assertTrue(regexes)
438
442
  self.assertTrue(any(re.search(rx, '/qualcuno/node_modules/x') for rx in regexes))
@@ -146,6 +146,21 @@ class AgentTemplateTest(unittest.TestCase):
146
146
  for needle in ('RunAtLoad', 'KeepAlive', '__REPO__', '__PYTHON__'):
147
147
  self.assertIn(needle, text, needle)
148
148
 
149
+ def test_install_sh_interprete_stabile(self):
150
+ """SPEC.md §8.4 (0.5.1): il default renderizzato è lo shim di sistema
151
+ /usr/bin/python3, MAI un path Cellar di Homebrew — un `brew upgrade`
152
+ cancella il Cellar sotto il processo vivo e TCC non lo identifica più."""
153
+ with open(os.path.join(REPO, 'install.sh'), encoding='utf-8') as fh:
154
+ text = fh.read()
155
+ self.assertIn('SAFEKEEP_PYTHON:-/usr/bin/python3', text)
156
+ self.assertNotIn('/opt/homebrew/bin/python3', text,
157
+ 'il default non deve più puntare al Cellar di brew')
158
+ self.assertIn('py_ok', text, "validazione ≥ 3.9 dell'interprete")
159
+ for name in ('com.safekeep.agent.plist', 'com.safekeep.trend.plist'):
160
+ with open(os.path.join(REPO, 'launchd', name), encoding='utf-8') as fh:
161
+ tpl = fh.read()
162
+ self.assertIn('default: /usr/bin/python3', tpl, name)
163
+
149
164
 
150
165
  class RecentPermissionErrorsTest(unittest.TestCase):
151
166
  """SPEC.md §14.4: errori di permesso recenti nel log, finestra 24h,
@@ -199,6 +214,78 @@ class RecentPermissionErrorsTest(unittest.TestCase):
199
214
  self.assertIsNotNone(plat.PERM_ERR_RE.search(riga))
200
215
  self.assertIsNone(plat.PERM_ERR_RE.search('INFO reconcile: 0 copie'))
201
216
 
217
+ def test_suffisso_ripetuto_del_throttle_contato_come_N_piu_1(self):
218
+ """SPEC.md §10.1/§14.4 (0.5.1): una riga `— ripetuto N volte`
219
+ rappresenta se stessa + i N fallimenti compressi → count_24h resta il
220
+ numero VERO di errori anche con le righe throttolate."""
221
+ text = (
222
+ f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
223
+ "a → b: [Errno 1] Operation not permitted: '/x.tmp.1'\n"
224
+ f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR copia fallita '
225
+ "a → b: [Errno 1] Operation not permitted: '/x.tmp.1' — ripetuto 49 volte\n"
226
+ )
227
+ count, last = plat.recent_permission_errors(self._log(text), now=self.NOW)
228
+ self.assertEqual(count, 1 + 1 + 49, 'riga base + (riga suffisso + 49)')
229
+ self.assertEqual(last, self.NOW - timedelta(hours=1))
230
+
231
+ def test_riga_senza_suffisso_restano_una_ciascuna(self):
232
+ text = (f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
233
+ "a → b: [Errno 1] Operation not permitted: '/x'\n"
234
+ f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR copia fallita '
235
+ "a → b: [Errno 1] Operation not permitted: '/y'\n")
236
+ count, _ = plat.recent_permission_errors(self._log(text), now=self.NOW)
237
+ self.assertEqual(count, 2)
238
+
239
+
240
+ class DaemonExeTest(unittest.TestCase):
241
+ """SPEC.md §14.4 (0.5.1): exe del processo launchd, None se non
242
+ determinabile — Darwin-only, solo lettura, casi finti."""
243
+
244
+ LAUNCHCTL_OK = subprocess.CompletedProcess(
245
+ [], 0, stdout='\tstate = running\n\tpid = 4242\n', stderr='')
246
+ PS_OK = subprocess.CompletedProcess(
247
+ [], 0, stdout='/opt/shim/python3\n', stderr='')
248
+
249
+ def run_mock(self, results):
250
+ with mock.patch('sys.platform', 'darwin'), \
251
+ mock.patch.object(plat.subprocess, 'run',
252
+ side_effect=results) as run:
253
+ return plat.daemon_exe(), run
254
+
255
+ def test_non_darwin_ritorna_none_senza_lanciare_niente(self):
256
+ with mock.patch('sys.platform', 'linux'), \
257
+ mock.patch.object(plat.subprocess, 'run') as run:
258
+ self.assertIsNone(plat.daemon_exe())
259
+ run.assert_not_called()
260
+
261
+ def test_job_non_caricato_o_senza_pid(self):
262
+ for result in (subprocess.CompletedProcess([], 1, stdout='slot absent', stderr=''),
263
+ subprocess.CompletedProcess([], 0, stdout='state = not running\n',
264
+ stderr='')):
265
+ with self.subTest(result=result.stdout):
266
+ exe, run = self.run_mock([result])
267
+ self.assertIsNone(exe)
268
+ # job ok ma `ps` fallito → non determinabile
269
+ exe, _ = self.run_mock([self.LAUNCHCTL_OK,
270
+ subprocess.CompletedProcess([], 1, stdout='', stderr='x')])
271
+ self.assertIsNone(exe)
272
+
273
+ def test_launchctl_o_ps_assenti(self):
274
+ exe, run = self.run_mock([OSError('launchctl mancante')])
275
+ self.assertIsNone(exe)
276
+
277
+ def test_eseguibile_letto_da_ps(self):
278
+ exe, run = self.run_mock([self.LAUNCHCTL_OK, self.PS_OK])
279
+ self.assertEqual(exe, '/opt/shim/python3')
280
+ self.assertEqual(run.call_args_list[1].args[0][:4],
281
+ ['ps', '-p', '4242', '-o'])
282
+
283
+ def test_ps_senza_output(self):
284
+ exe, _ = self.run_mock([self.LAUNCHCTL_OK,
285
+ subprocess.CompletedProcess([], 0, stdout='\n',
286
+ stderr='')])
287
+ self.assertIsNone(exe)
288
+
202
289
 
203
290
  class LingerTest(unittest.TestCase):
204
291
  """SPEC.md §14.4: linger systemd, directory finta al posto di
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes