safekeep 0.3.0__tar.gz → 0.4.0__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 (27) hide show
  1. {safekeep-0.3.0/safekeep.egg-info → safekeep-0.4.0}/PKG-INFO +1 -1
  2. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/__init__.py +1 -1
  3. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/cli.py +50 -1
  4. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/daemon.py +2 -2
  5. safekeep-0.4.0/safekeep/platform.py +147 -0
  6. {safekeep-0.3.0 → safekeep-0.4.0/safekeep.egg-info}/PKG-INFO +1 -1
  7. {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_cli.py +156 -1
  8. safekeep-0.4.0/tests/test_platform.py +296 -0
  9. safekeep-0.3.0/safekeep/platform.py +0 -57
  10. safekeep-0.3.0/tests/test_platform.py +0 -150
  11. {safekeep-0.3.0 → safekeep-0.4.0}/LICENSE +0 -0
  12. {safekeep-0.3.0 → safekeep-0.4.0}/README.md +0 -0
  13. {safekeep-0.3.0 → safekeep-0.4.0}/pyproject.toml +0 -0
  14. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/config.py +0 -0
  15. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/copier.py +0 -0
  16. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/matcher.py +0 -0
  17. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/volumes.py +0 -0
  18. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/SOURCES.txt +0 -0
  19. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/dependency_links.txt +0 -0
  20. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/entry_points.txt +0 -0
  21. {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/top_level.txt +0 -0
  22. {safekeep-0.3.0 → safekeep-0.4.0}/setup.cfg +0 -0
  23. {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_config.py +0 -0
  24. {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_copier.py +0 -0
  25. {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_daemon.py +0 -0
  26. {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_matcher.py +0 -0
  27. {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -3,4 +3,4 @@ import logging
3
3
 
4
4
  logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
5
 
6
- __version__ = '0.3.0'
6
+ __version__ = '0.4.0'
@@ -14,11 +14,17 @@ from safekeep.copier import TMP_INFIX
14
14
  from safekeep.daemon import Daemon, setup_logging
15
15
  from safekeep.platform import (
16
16
  INOTIFY_MIN_WATCHES,
17
+ LOG_PATH,
17
18
  fswatch_monitor,
18
19
  inotify_limit,
19
20
  install_hint,
20
21
  is_darwin,
21
22
  is_linux,
23
+ is_wsl,
24
+ linger_enabled,
25
+ recent_permission_errors,
26
+ systemd_user_available,
27
+ wsl_distro,
22
28
  )
23
29
  from safekeep.volumes import dest_state
24
30
 
@@ -54,7 +60,8 @@ def build_parser():
54
60
  help='config, progetti e stato delle dest — sola lettura')
55
61
  sub.add_parser('doctor', parents=[common],
56
62
  help='diagnostica: config, fswatch + monitor di piattaforma, python, '
57
- 'TCC/plist (macOS), inotify/systemd (Linux) — exit ≠ 0 se un check '
63
+ 'errori di permesso recenti nel log, TCC/plist (macOS), '
64
+ 'inotify/systemd/linger (Linux) — exit ≠ 0 se un check '
58
65
  'fatale fallisce')
59
66
  return parser
60
67
 
@@ -226,6 +233,37 @@ def cmd_doctor(args):
226
233
  out(sys.version_info >= (3, 9),
227
234
  f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
228
235
 
236
+ # Errori di permesso recenti nel log corrente (SPEC.md §14.4): sintomo
237
+ # delle ultime 24h, non stato del sistema → sempre NON fatale
238
+ log = os.path.expanduser(LOG_PATH)
239
+ recent = recent_permission_errors()
240
+ if recent is None:
241
+ out(True, f'errori di permesso recenti: log assente — check saltato ({log})')
242
+ elif recent[0] > 0:
243
+ out(False, f'errori di permesso recenti: {recent[0]} nelle ultime 24h '
244
+ f'(ultimo: {recent[1]}) — log: {log} — warning non fatale: '
245
+ 'vedi i pending con `safekeep sync-once --dry-run`, poi '
246
+ 'riconcedere i permessi al volume (FDA/Full Disk Access)')
247
+ else:
248
+ out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
249
+
250
+ # WSL (SPEC.md §17.2): tre righe informative — nessuna è mai fatale
251
+ if is_wsl():
252
+ out(True, f'WSL rilevato: {wsl_distro() or "sconosciuta"} '
253
+ '(supporto best-effort, SPEC §17)')
254
+ if not systemd_user_available():
255
+ out(False, 'systemd user non disponibile (WSL1 o systemd spento) — '
256
+ 'abilita systemd in /etc/wsl.conf: [boot] systemd=true '
257
+ '(poi wsl --shutdown da Windows) oppure esegui '
258
+ '`safekeep run` a mano — warning non fatale')
259
+ if daemon is not None:
260
+ sotto_mnt = [p for p in list(daemon.cfg.sources)
261
+ + list(daemon.cfg.dests) if p.startswith('/mnt/')]
262
+ if sotto_mnt:
263
+ out(True, f'drvfs: {len(sotto_mnt)} path sotto /mnt/ — I/O lento '
264
+ 'e case-insensitive di default: valuta una dest su ext4 '
265
+ 'nativa della distro (SPEC §17.2)')
266
+
229
267
  # Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
230
268
  # (SPEC.md §14.4) — informativo, NON fatale
231
269
  if is_linux():
@@ -296,6 +334,17 @@ def cmd_doctor(args):
296
334
  'del template — rilancia install.sh', is_fatal=True)
297
335
  else:
298
336
  out(True, f'unit systemd: {unit}')
337
+ # linger: solo dove la unit è installata (SPEC.md §14.4) — non fatale
338
+ enabled = linger_enabled()
339
+ if enabled is None:
340
+ out(True, 'linger: non verificabile (/var/lib/systemd/linger '
341
+ 'assente) — check saltato')
342
+ elif enabled:
343
+ out(True, 'linger: abilitato (agent al boot anche senza login)')
344
+ else:
345
+ out(False, "linger: NON abilitato — l'agent non partirà al boot "
346
+ 'senza login → loginctl enable-linger $USER — '
347
+ 'warning non fatale')
299
348
 
300
349
  return 1 if fatal else 0
301
350
 
@@ -27,7 +27,7 @@ from .config import (
27
27
  )
28
28
  from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
29
29
  from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
30
- from .platform import fswatch_monitor, init_system
30
+ from .platform import LOG_PATH, fswatch_monitor, init_system
31
31
  from .volumes import backoff_schedule, dest_state, partition_dests
32
32
 
33
33
  log = logging.getLogger('safekeep')
@@ -271,7 +271,7 @@ def setup_logging(level='info', logfile=None):
271
271
  logger._safekeep_handlers = True
272
272
  fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
273
273
  if logfile is None:
274
- logfile = os.path.expanduser('~/.local/state/safekeep/safekeep.log')
274
+ logfile = os.path.expanduser(LOG_PATH)
275
275
  try:
276
276
  os.makedirs(os.path.dirname(logfile), exist_ok=True)
277
277
  handler = logging.handlers.RotatingFileHandler(
@@ -0,0 +1,147 @@
1
+ """Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
2
+
3
+ Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
4
+ init system, limite di watch di inotify, log corrente, linger systemd,
5
+ rilevamento WSL. Tutto letto **a chiamata** (mai a import), così i test
6
+ patchano `sys.platform` ed esercitano entrambi i rami anche girando su una
7
+ sola piattaforma.
8
+
9
+ I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
10
+ (FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
11
+ il nome NON è `inotify` (SPEC.md §14.2).
12
+ """
13
+ import getpass
14
+ import os
15
+ import platform as _platform
16
+ import re
17
+ import shutil
18
+ import sys
19
+ from datetime import datetime
20
+
21
+ INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
22
+ INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
23
+ LOG_PATH = '~/.local/state/safekeep/safekeep.log' # log corrente (SPEC.md §3)
24
+ LINGER_DIR = '/var/lib/systemd/linger'
25
+ SYSTEMD_RUN_PATH = '/run/systemd/system' # assente su WSL1/systemd off
26
+ # EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
27
+ # + testo del kernel (SPEC.md §14.4)
28
+ PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
29
+
30
+
31
+ def is_darwin():
32
+ return sys.platform == 'darwin'
33
+
34
+
35
+ def is_linux():
36
+ return sys.platform == 'linux'
37
+
38
+
39
+ def fswatch_monitor():
40
+ """Monitor fswatch per la piattaforma (SPEC.md §14.2), usato da
41
+ `daemon.fswatch_argv()` e dal check monitor di `doctor`."""
42
+ return 'fsevents_monitor' if is_darwin() else 'inotify_monitor'
43
+
44
+
45
+ def init_system():
46
+ """`launchd` (Darwin) o `systemd` (Linux) — SPEC.md §14.3."""
47
+ return 'launchd' if is_darwin() else 'systemd'
48
+
49
+
50
+ def install_hint():
51
+ """Come installare `fswatch` su questa macchina (SPEC.md §14.4)."""
52
+ if is_darwin():
53
+ return 'brew install fswatch'
54
+ for pm, cmd in (('pacman', 'pacman -S fswatch'),
55
+ ('apt-get', 'sudo apt-get install fswatch'),
56
+ ('dnf', 'sudo dnf install fswatch')):
57
+ if shutil.which(pm):
58
+ return cmd
59
+ return 'installa fswatch col package manager della tua distro'
60
+
61
+
62
+ def inotify_limit(path=INOTIFY_LIMIT_PATH):
63
+ """`fs.inotify.max_user_watches`, o None se il file non c'è o è illeggibile
64
+ (SPEC.md §14.4: su Linux è il rischio #1 sugli alberi grandi)."""
65
+ try:
66
+ with open(path) as fh:
67
+ return int(fh.read().strip())
68
+ except (OSError, ValueError):
69
+ return None
70
+
71
+
72
+ def recent_permission_errors(path=None, now=None, window=86400):
73
+ """`(conteggio, ultimo timestamp)` degli errori di permesso nelle ultime
74
+ `window` secondi del log corrente, o None se il log non c'è/non è leggibile
75
+ (SPEC.md §14.4: check non fatale, finestra 24h — segue l'incidente EPERM).
76
+
77
+ Il timestamp è il prefisso `%(asctime)s` della riga (`YYYY-MM-DD HH:MM:SS`,
78
+ prime 19 posizioni). **Limite dichiarato**: una riga senza timestamp
79
+ parsificabile o fuori dalla finestra (clock skew incluso) non viene contata —
80
+ non essendo sicuri che sia recente, non la contiamo: il conteggio è un minimo."""
81
+ if path is None:
82
+ path = os.path.expanduser(LOG_PATH)
83
+ if now is None:
84
+ now = datetime.now()
85
+ count, last = 0, None
86
+ try:
87
+ with open(path, encoding='utf-8', errors='replace') as fh:
88
+ for line in fh:
89
+ if not PERM_ERR_RE.search(line):
90
+ continue
91
+ try:
92
+ ts = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S')
93
+ except ValueError:
94
+ continue # timestamp illeggibile → non sicuri, salta
95
+ age = (now - ts).total_seconds()
96
+ if not 0 <= age <= window:
97
+ continue # troppo vecchio (o nel futuro: clock skew)
98
+ count += 1
99
+ if last is None or ts > last:
100
+ last = ts
101
+ except OSError:
102
+ return None
103
+ return count, last
104
+
105
+
106
+ def linger_enabled(user=None, linger_dir=None):
107
+ """`True`/`False` se l'utente è in `/var/lib/systemd/linger/`, None se la
108
+ directory non c'è (niente systemd → non determinabile, SPEC.md §14.4).
109
+ Legge la directory direttamente: stessa informazione di
110
+ `loginctl show-user $USER -p Linger`, senza root."""
111
+ if linger_dir is None:
112
+ linger_dir = LINGER_DIR
113
+ try:
114
+ entries = os.listdir(linger_dir)
115
+ except OSError:
116
+ return None
117
+ if user is None:
118
+ try:
119
+ user = getpass.getuser()
120
+ except OSError:
121
+ return None
122
+ return user in entries
123
+
124
+
125
+ def is_wsl():
126
+ """True dentro WSL (Windows Subsystem for Linux) — SPEC.md §17.1.
127
+
128
+ `sys.platform` resta `linux` su WSL: il rilevamento passa dall'env
129
+ `WSL_DISTRO_NAME` (ogni distro WSL lo settà) oppure dal kernel
130
+ (`platform.release()` contiene `microsoft` su WSL1 e WSL2)."""
131
+ if os.environ.get('WSL_DISTRO_NAME'):
132
+ return True
133
+ return 'microsoft' in _platform.release().lower()
134
+
135
+
136
+ def wsl_distro():
137
+ """Nome della distro WSL (env `WSL_DISTRO_NAME`), vuoto se assente —
138
+ SPEC.md §17.1."""
139
+ return os.environ.get('WSL_DISTRO_NAME', '')
140
+
141
+
142
+ def systemd_user_available(path=None):
143
+ """True se c'è un init systemd vivo (`/run/systemd/system`): assente su
144
+ WSL1 o con systemd spento in WSL2 — SPEC.md §17.2."""
145
+ if path is None:
146
+ path = SYSTEMD_RUN_PATH
147
+ return os.path.exists(path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -4,6 +4,7 @@ Ogni run è un subprocess con HOME finto: niente scritture nella home reale.
4
4
  """
5
5
  import argparse
6
6
  import contextlib
7
+ import getpass
7
8
  import io
8
9
  import os
9
10
  import shutil
@@ -11,9 +12,10 @@ import subprocess
11
12
  import sys
12
13
  import tempfile
13
14
  import unittest
15
+ from datetime import datetime, timedelta
14
16
  from unittest import mock
15
17
 
16
- from safekeep import cli
18
+ from safekeep import cli, platform as plat
17
19
  from safekeep.platform import fswatch_monitor
18
20
 
19
21
  REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -271,5 +273,158 @@ class PruneCliTest(CliTestCase):
271
273
  self.assertTrue(os.path.exists(os.path.join(self.dest, keep)), keep)
272
274
 
273
275
 
276
+ class DoctorPermessiRecentiTest(CliTestCase):
277
+ """SPEC.md §14.4: errori di permesso nel log corrente — finestra 24h,
278
+ warning non fatale, log assente ⇒ check saltato."""
279
+
280
+ def log_path(self):
281
+ return os.path.join(self.home, '.local/state/safekeep/safekeep.log')
282
+
283
+ @staticmethod
284
+ def stamp(delta):
285
+ return (datetime.now() - delta).strftime('%Y-%m-%d %H:%M:%S,000')
286
+
287
+ def riga(self, delta, msg='[Errno 1] Operation not permitted: /x.tmp.1'):
288
+ return f'{self.stamp(delta)} ERROR copia fallita a → b: {msg}\n'
289
+
290
+ def riga_doctor(self, stdout):
291
+ self.assertIn('errori di permesso', stdout, stdout)
292
+ return next(l for l in stdout.splitlines() if 'errori di permesso' in l)
293
+
294
+ def test_errore_recente_warning_non_fatale(self):
295
+ write(self.log_path(), self.riga(timedelta(hours=3)))
296
+ r = self.cli('doctor', '--config', self.cfg)
297
+ line = self.riga_doctor(r.stdout)
298
+ self.assertTrue(line.startswith('✗'), line)
299
+ for needle in ('nelle ultime 24h', 'warning non fatale',
300
+ 'sync-once --dry-run', 'Full Disk Access',
301
+ self.log_path()):
302
+ self.assertIn(needle, line, needle)
303
+ self.assertEqual(r.returncode, 0, r.stdout) # NON fatale
304
+
305
+ def test_errore_vecchio_verde(self):
306
+ write(self.log_path(),
307
+ self.riga(timedelta(days=3), '[Errno 13] Permission denied'))
308
+ r = self.cli('doctor', '--config', self.cfg)
309
+ line = self.riga_doctor(r.stdout)
310
+ self.assertTrue(line.startswith('✔'), line)
311
+ self.assertIn('nessuno nelle ultime 24h', line)
312
+
313
+ def test_log_assente_check_saltato(self):
314
+ r = self.cli('doctor', '--config', self.cfg) # nessun log nel HOME finto
315
+ line = self.riga_doctor(r.stdout)
316
+ self.assertTrue(line.startswith('✔'), line)
317
+ self.assertIn('check saltato', line)
318
+
319
+
320
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
321
+ class DoctorLingerTest(CliTestCase):
322
+ """SPEC.md §14.4: linger solo dove c'è la unit systemd, warning non fatale."""
323
+
324
+ def doctor_linux(self, unit=True, linger=()):
325
+ if unit:
326
+ write(os.path.join(self.home, '.config/systemd/user/safekeep.service'),
327
+ '[Unit]\nDescription=safekeep\n')
328
+ linger_dir = tempfile.mkdtemp()
329
+ self.addCleanup(shutil.rmtree, linger_dir, ignore_errors=True)
330
+ for name in linger:
331
+ open(os.path.join(linger_dir, name), 'w').close()
332
+ args = argparse.Namespace(config=self.cfg, v=False)
333
+ buf = io.StringIO()
334
+ with mock.patch.dict(os.environ, {'HOME': self.home}), \
335
+ mock.patch.object(cli, 'is_linux', return_value=True), \
336
+ mock.patch.object(cli, 'is_darwin', return_value=False), \
337
+ mock.patch.object(plat, 'LINGER_DIR', linger_dir), \
338
+ contextlib.redirect_stdout(buf):
339
+ code = cli.cmd_doctor(args)
340
+ return code, buf.getvalue()
341
+
342
+ @staticmethod
343
+ def riga_linger(out):
344
+ return next(l for l in out.splitlines() if 'linger' in l)
345
+
346
+ def test_unit_assenta_niente_linger(self):
347
+ code, out = self.doctor_linux(unit=False)
348
+ self.assertNotIn('linger', out, 'il check esiste solo con la unit installata')
349
+ self.assertEqual(code, 0, out)
350
+
351
+ def test_linger_disabilitato_warning_non_fatale(self):
352
+ code, out = self.doctor_linux(unit=True)
353
+ line = self.riga_linger(out)
354
+ self.assertTrue(line.startswith('✗'), line)
355
+ self.assertIn('loginctl enable-linger', line)
356
+ self.assertIn('warning non fatale', line)
357
+ self.assertEqual(code, 0, out) # NON fatale
358
+
359
+ def test_linger_abilitato_verde(self):
360
+ code, out = self.doctor_linux(unit=True, linger=(getpass.getuser(),))
361
+ line = self.riga_linger(out)
362
+ self.assertTrue(line.startswith('✔'), line)
363
+ self.assertEqual(code, 0, out)
364
+
365
+
366
+ class DoctorWslTest(CliTestCase):
367
+ """SPEC.md §17.2: le tre righe WSL di `doctor` sono informative e non fatali."""
368
+
369
+ def doctor_wsl(self, distro='Ubuntu', systemd=False, dest='/mnt/c/backup'):
370
+ cfg = write(os.path.join(self.tmp, 'wsl.cfg'), f'dest: {dest}\n')
371
+ args = argparse.Namespace(config=cfg, v=False)
372
+ buf = io.StringIO()
373
+ with mock.patch.dict(os.environ, {'HOME': self.home}), \
374
+ mock.patch.object(cli, 'is_wsl', return_value=True), \
375
+ mock.patch.object(cli, 'wsl_distro', return_value=distro), \
376
+ mock.patch.object(cli, 'systemd_user_available',
377
+ return_value=systemd), \
378
+ contextlib.redirect_stdout(buf):
379
+ code = cli.cmd_doctor(args)
380
+ return code, buf.getvalue()
381
+
382
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
383
+ def test_wsle_righe_con_hint_systemd_e_drvfs(self):
384
+ code, out = self.doctor_wsl()
385
+ self.assertIn('✔ WSL rilevato: Ubuntu', out)
386
+ hint = next(l for l in out.splitlines()
387
+ if 'systemd user non disponibile' in l)
388
+ self.assertTrue(hint.startswith('✗'), hint)
389
+ for needle in ('/etc/wsl.conf', 'systemd=true', 'safekeep run',
390
+ 'warning non fatale'):
391
+ self.assertIn(needle, hint, needle)
392
+ drvfs = next(l for l in out.splitlines() if 'drvfs' in l)
393
+ self.assertIn('/mnt/', drvfs)
394
+ self.assertEqual(code, 0, out) # mai fatale
395
+
396
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
397
+ def test_systemd_attivo_nessun_hint(self):
398
+ code, out = self.doctor_wsl(systemd=True)
399
+ self.assertNotIn('systemd user non disponibile', out)
400
+ self.assertIn('WSL rilevato', out)
401
+ self.assertEqual(code, 0, out)
402
+
403
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
404
+ def test_nessun_path_sotto_mnt_niente_drvfs(self):
405
+ code, out = self.doctor_wsl(dest=os.path.join(self.tmp, 'dst'))
406
+ self.assertNotIn('drvfs', out)
407
+ self.assertEqual(code, 0, out)
408
+
409
+ def test_detection_reale_via_env_fino_a_doctor(self):
410
+ """Detection reale (env) fino all'output di doctor: niente patch."""
411
+ env = dict(os.environ, HOME=self.home, WSL_DISTRO_NAME='Debian')
412
+ r = subprocess.run([sys.executable, CLI, 'doctor', '--config', self.cfg],
413
+ capture_output=True, text=True, env=env, cwd=REPO,
414
+ timeout=60)
415
+ self.assertIn('WSL rilevato: Debian', r.stdout)
416
+
417
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
418
+ def test_senza_wsl_nulla_cambia(self):
419
+ """Su Linux/macOS non-WSL nessuna riga WSL compare (SPEC §17)."""
420
+ env = {k: v for k, v in os.environ.items() if k != 'WSL_DISTRO_NAME'}
421
+ env['HOME'] = self.home
422
+ r = subprocess.run([sys.executable, CLI, 'doctor', '--config', self.cfg],
423
+ capture_output=True, text=True, env=env, cwd=REPO,
424
+ timeout=60)
425
+ self.assertNotIn('WSL', r.stdout)
426
+ self.assertNotIn('drvfs', r.stdout)
427
+
428
+
274
429
  if __name__ == '__main__':
275
430
  unittest.main()
@@ -0,0 +1,296 @@
1
+ """Helper di piattaforma e agent systemd (SPEC.md §14).
2
+
3
+ Entrambi i rami (Darwin/Linux) si esercitano patchando `sys.platform`, così la
4
+ suite copre il design di portabilità anche girando su una sola piattaforma.
5
+ """
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import unittest
11
+ from datetime import datetime, timedelta
12
+ from unittest import mock
13
+
14
+ from safekeep import platform as plat
15
+
16
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17
+
18
+
19
+ class MonitorTest(unittest.TestCase):
20
+ def test_monitor_dichiarato_per_piattaforma(self):
21
+ with mock.patch('sys.platform', 'darwin'):
22
+ self.assertEqual(plat.fswatch_monitor(), 'fsevents_monitor')
23
+ with mock.patch('sys.platform', 'linux'):
24
+ self.assertEqual(plat.fswatch_monitor(), 'inotify_monitor')
25
+
26
+ def test_monitor_atteso_e_in_fswatch_M(self):
27
+ """Guardrail contro il nome sbagliato: `-m inotify` (senza `_monitor`)
28
+ non è un alias accettato da fswatch — qui si verifica che il nome che
29
+ passiamo in `-m` sia proprio uno di quelli che `fswatch -M` elenca."""
30
+ exe = shutil.which('fswatch')
31
+ if exe is None:
32
+ self.skipTest('fswatch non installato')
33
+ out = subprocess.run([exe, '-M'], capture_output=True, text=True,
34
+ timeout=15).stdout
35
+ self.assertIn(plat.fswatch_monitor(), out)
36
+
37
+
38
+ class InitSystemTest(unittest.TestCase):
39
+ def test_init_system_per_piattaforma(self):
40
+ with mock.patch('sys.platform', 'darwin'):
41
+ self.assertEqual(plat.init_system(), 'launchd')
42
+ with mock.patch('sys.platform', 'linux'):
43
+ self.assertEqual(plat.init_system(), 'systemd')
44
+
45
+ def test_is_darwin_e_is_linux(self):
46
+ with mock.patch('sys.platform', 'darwin'):
47
+ self.assertTrue(plat.is_darwin())
48
+ self.assertFalse(plat.is_linux())
49
+ with mock.patch('sys.platform', 'linux'):
50
+ self.assertTrue(plat.is_linux())
51
+ self.assertFalse(plat.is_darwin())
52
+
53
+
54
+ class InstallHintTest(unittest.TestCase):
55
+ def test_darwin_usa_brew(self):
56
+ with mock.patch('sys.platform', 'darwin'):
57
+ self.assertEqual(plat.install_hint(), 'brew install fswatch')
58
+
59
+ def test_linux_rileva_il_package_manager(self):
60
+ casi = (('pacman', 'pacman -S fswatch'),
61
+ ('apt-get', 'sudo apt-get install fswatch'),
62
+ ('dnf', 'sudo dnf install fswatch'))
63
+ for pm, atteso in casi:
64
+ with self.subTest(pm=pm), \
65
+ mock.patch('sys.platform', 'linux'), \
66
+ mock.patch('shutil.which',
67
+ side_effect=lambda name, pm=pm:
68
+ '/usr/bin/' + name if name == pm else None):
69
+ self.assertEqual(plat.install_hint(), atteso)
70
+
71
+ def test_linux_senza_package_manager_noto(self):
72
+ with mock.patch('sys.platform', 'linux'), \
73
+ mock.patch('shutil.which', return_value=None):
74
+ self.assertIn('fswatch', plat.install_hint())
75
+
76
+
77
+ class InotifyLimitTest(unittest.TestCase):
78
+ """SPEC.md §14.4: check informativo non fatale sul limite di watch."""
79
+
80
+ def _fake(self, text):
81
+ fd, path = tempfile.mkstemp()
82
+ self.addCleanup(os.unlink, path)
83
+ with os.fdopen(fd, 'w') as fh:
84
+ fh.write(text)
85
+ return path
86
+
87
+ def test_lettura_da_file_finto(self):
88
+ self.assertEqual(plat.inotify_limit(self._fake('524288\n')), 524288)
89
+ self.assertEqual(plat.inotify_limit(self._fake('8192')), 8192)
90
+
91
+ def test_valore_invalido_o_file_mancante(self):
92
+ self.assertIsNone(plat.inotify_limit(self._fake('non-è-un-numbero\n')))
93
+ self.assertIsNone(plat.inotify_limit('/proc/assente/inotify'))
94
+
95
+ def test_costanti(self):
96
+ self.assertEqual(plat.INOTIFY_MIN_WATCHES, 16384)
97
+ self.assertEqual(plat.INOTIFY_LIMIT_PATH,
98
+ '/proc/sys/fs/inotify/max_user_watches')
99
+
100
+
101
+ class PiattaformaCentralizzataTest(unittest.TestCase):
102
+ def test_sys_platform_letto_solo_nell_helper(self):
103
+ """SPEC.md §14.1: un solo posto legge `sys.platform`, niente check sparsi."""
104
+ hits = []
105
+ pkg = os.path.join(REPO, 'safekeep')
106
+ for name in sorted(os.listdir(pkg)):
107
+ if not name.endswith('.py'):
108
+ continue
109
+ with open(os.path.join(pkg, name), encoding='utf-8') as fh:
110
+ for i, line in enumerate(fh, 1):
111
+ if 'sys.platform' in line:
112
+ hits.append(f'{name}:{i}')
113
+ self.assertTrue(hits, 'nessun riferimento a sys.platform trovato')
114
+ self.assertTrue(all(h.startswith('platform.py:') for h in hits), hits)
115
+
116
+
117
+ class AgentTemplateTest(unittest.TestCase):
118
+ """SPEC.md §14.3: la unit systemd è un template con gli stessi placeholder
119
+ del plist e copre RunAtLoad/KeepAlive."""
120
+
121
+ def test_unit_systemd_template(self):
122
+ path = os.path.join(REPO, 'systemd', 'safekeep.service')
123
+ with open(path, encoding='utf-8') as fh:
124
+ text = fh.read()
125
+ for needle in ('ExecStart=__PYTHON__ __REPO__/bin/safekeep.py run',
126
+ 'Restart=always',
127
+ 'RestartSec=2',
128
+ 'StartLimitIntervalSec=0',
129
+ 'WantedBy=default.target'):
130
+ self.assertIn(needle, text, needle)
131
+ self.assertNotIn('/Users/', text, 'path assoluto nel template')
132
+
133
+ def test_gli_script_si_ramificano_per_os(self):
134
+ for name in ('install.sh', 'uninstall.sh'):
135
+ with self.subTest(script=name), \
136
+ open(os.path.join(REPO, name), encoding='utf-8') as fh:
137
+ text = fh.read()
138
+ self.assertIn('uname -s', text, name) # ramo Darwin/Linux
139
+ self.assertIn('launchctl', text, name) # ramo macOS invariato
140
+ self.assertIn('systemctl --user', text, name) # ramo Linux
141
+
142
+ def test_il_plist_macos_non_e_toccato(self):
143
+ with open(os.path.join(REPO, 'launchd', 'com.safekeep.agent.plist'),
144
+ encoding='utf-8') as fh:
145
+ text = fh.read()
146
+ for needle in ('RunAtLoad', 'KeepAlive', '__REPO__', '__PYTHON__'):
147
+ self.assertIn(needle, text, needle)
148
+
149
+
150
+ class RecentPermissionErrorsTest(unittest.TestCase):
151
+ """SPEC.md §14.4: errori di permesso recenti nel log, finestra 24h,
152
+ non fatale; righe senza timestamp sicuro non vengono contate."""
153
+
154
+ NOW = datetime(2026, 10, 4, 20, 0, 0)
155
+
156
+ def _log(self, text):
157
+ fd, path = tempfile.mkstemp(suffix='.log')
158
+ self.addCleanup(os.unlink, path)
159
+ with os.fdopen(fd, 'w') as fh:
160
+ fh.write(text)
161
+ return path
162
+
163
+ @staticmethod
164
+ def _stamp(dt):
165
+ return dt.strftime('%Y-%m-%d %H:%M:%S,000') # formato %(asctime)s
166
+
167
+ def test_ricente_vecchio_e_normale_nello_stesso_log(self):
168
+ text = (
169
+ f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
170
+ "a → b: [Errno 1] Operation not permitted: '/x.tmp.1'\n"
171
+ f'{self._stamp(self.NOW - timedelta(days=3))} ERROR copia fallita '
172
+ "a → b: [Errno 13] Permission denied: '/y'\n"
173
+ f'{self._stamp(self.NOW - timedelta(minutes=5))} INFO reconcile: 0 copie\n'
174
+ )
175
+ count, last = plat.recent_permission_errors(self._log(text), now=self.NOW)
176
+ self.assertEqual(count, 1, 'solo la riga nelle ultime 24h')
177
+ self.assertEqual(last, self.NOW - timedelta(hours=2))
178
+
179
+ def test_log_assente(self):
180
+ self.assertIsNone(
181
+ plat.recent_permission_errors('/non/esiste/safekeep.log'),
182
+ 'nessun log ⇒ check saltato, non warning')
183
+
184
+ def test_timestamp_illeggibile_non_e_contato(self):
185
+ text = ("senza-data ERROR [Errno 1] Operation not permitted: '/x'\n"
186
+ + f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR '
187
+ "[Errno 1] Operation not permitted: '/y'\n"
188
+ + f'{self._stamp(self.NOW + timedelta(hours=1))} ERROR '
189
+ "[Errno 1] Operation not permitted: '/z'\n")
190
+ count, _ = plat.recent_permission_errors(self._log(text), now=self.NOW)
191
+ self.assertEqual(count, 1, 'non sicuri (o clock skew) ⇒ non contiamo')
192
+
193
+ def test_copre_eperm_e_eacces(self):
194
+ for riga in ('[Errno 1] Operation not permitted',
195
+ 'qualcosa Operation not permitted',
196
+ '[Errno 13] Permission denied',
197
+ 'qualcosa Permission denied'):
198
+ with self.subTest(riga=riga):
199
+ self.assertIsNotNone(plat.PERM_ERR_RE.search(riga))
200
+ self.assertIsNone(plat.PERM_ERR_RE.search('INFO reconcile: 0 copie'))
201
+
202
+
203
+ class LingerTest(unittest.TestCase):
204
+ """SPEC.md §14.4: linger systemd, directory finta al posto di
205
+ /var/lib/systemd/linger."""
206
+
207
+ def _dir(self, *entries):
208
+ path = tempfile.mkdtemp()
209
+ self.addCleanup(shutil.rmtree, path, ignore_errors=True)
210
+ for name in entries:
211
+ open(os.path.join(path, name), 'w').close()
212
+ return path
213
+
214
+ def test_abilitato_disabilitato_e_dir_assente(self):
215
+ d = self._dir('utente')
216
+ self.assertTrue(plat.linger_enabled(user='utente', linger_dir=d))
217
+ self.assertFalse(plat.linger_enabled(user='altro', linger_dir=d))
218
+ self.assertIsNone(
219
+ plat.linger_enabled(user='utente',
220
+ linger_dir=os.path.join(os.sep, 'niente', 'qui')),
221
+ 'niente systemd ⇒ non determinabile, check saltato')
222
+
223
+ def test_user_default_e_costante(self):
224
+ self.assertEqual(plat.LINGER_DIR, '/var/lib/systemd/linger')
225
+ with mock.patch('getpass.getuser', return_value='chi'):
226
+ self.assertTrue(plat.linger_enabled(linger_dir=self._dir('chi')))
227
+ self.assertFalse(plat.linger_enabled(linger_dir=self._dir()))
228
+
229
+
230
+ class WslTest(unittest.TestCase):
231
+ """SPEC.md §17.1: rilevamento WSL via env o kernel, letto a chiamata."""
232
+
233
+ def test_env_wsl_distro_name(self):
234
+ with mock.patch.dict(os.environ, {'WSL_DISTRO_NAME': 'Ubuntu'}):
235
+ self.assertTrue(plat.is_wsl())
236
+ self.assertEqual(plat.wsl_distro(), 'Ubuntu')
237
+
238
+ def test_kernel_microsoft_senza_env(self):
239
+ for release in ('5.15.167.4-microsoft-standard-WSL2', # WSL2
240
+ '4.4.0-19041-Microsoft'): # WSL1 (maiuscolo)
241
+ with self.subTest(release=release), \
242
+ mock.patch.dict(os.environ, {'WSL_DISTRO_NAME': ''}), \
243
+ mock.patch.object(plat, '_platform') as p:
244
+ p.release.return_value = release
245
+ self.assertTrue(plat.is_wsl())
246
+ self.assertEqual(plat.wsl_distro(), '')
247
+
248
+ def test_non_wsl(self):
249
+ with mock.patch.dict(os.environ, {'WSL_DISTRO_NAME': ''}), \
250
+ mock.patch.object(plat, '_platform') as p:
251
+ p.release.return_value = '24.6.0' # macOS, niente microsoft
252
+ self.assertFalse(plat.is_wsl())
253
+ self.assertEqual(plat.wsl_distro(), '')
254
+
255
+ def test_systemd_user_available(self):
256
+ with tempfile.TemporaryDirectory() as d:
257
+ self.assertTrue(plat.systemd_user_available(path=d))
258
+ self.assertFalse(
259
+ plat.systemd_user_available(path='/non/esiste/qui'),
260
+ '/run/systemd/system assente = WSL1 o systemd spento (SPEC §17.2)')
261
+
262
+
263
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
264
+ class InstallShSenzaSystemdTest(unittest.TestCase):
265
+ """SPEC.md §17.3: `install.sh` su Linux senza systemd → unit NON installata,
266
+ istruzioni di avvio manuale ed exit 0 (nessun errore duro)."""
267
+
268
+ def test_ramo_linux_senza_systemd_esce_0(self):
269
+ if os.path.isdir('/run/systemd/system'):
270
+ self.skipTest('questa macchina ha systemd: ramo diverso')
271
+ tmp = tempfile.mkdtemp()
272
+ self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
273
+ home, fake_bin = os.path.join(tmp, 'home'), os.path.join(tmp, 'bin')
274
+ os.makedirs(home)
275
+ os.makedirs(fake_bin)
276
+ uname = os.path.join(fake_bin, 'uname') # uname finto → Linux
277
+ with open(uname, 'w') as fh:
278
+ fh.write('#!/bin/bash\necho Linux\n')
279
+ os.chmod(uname, 0o755)
280
+ env = dict(os.environ, HOME=home,
281
+ PATH=fake_bin + os.pathsep + os.environ['PATH'])
282
+ r = subprocess.run(['bash', os.path.join(REPO, 'install.sh')],
283
+ capture_output=True, text=True, env=env, timeout=60)
284
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
285
+ self.assertIn('systemd non disponibile', r.stdout)
286
+ self.assertIn('safekeep.py run', r.stdout) # avvio manuale
287
+ self.assertIn('systemd=true', r.stdout) # hint /etc/wsl.conf
288
+ self.assertTrue(os.path.exists(os.path.join(home, '.safekeep')),
289
+ 'la config viene creata comunque')
290
+ self.assertFalse(os.path.exists(
291
+ os.path.join(home, '.config/systemd/user/safekeep.service')),
292
+ 'niente unit senza systemd')
293
+
294
+
295
+ if __name__ == '__main__':
296
+ unittest.main()
@@ -1,57 +0,0 @@
1
- """Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
2
-
3
- Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
4
- init system, limite di watch di inotify. Tutto letto **a chiamata** (mai a
5
- import), così i test patchano `sys.platform` ed esercitano entrambi i rami
6
- anche girando su una sola piattaforma.
7
-
8
- I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
9
- (FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
10
- il nome NON è `inotify` (SPEC.md §14.2).
11
- """
12
- import shutil
13
- import sys
14
-
15
- INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
16
- INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
17
-
18
-
19
- def is_darwin():
20
- return sys.platform == 'darwin'
21
-
22
-
23
- def is_linux():
24
- return sys.platform == 'linux'
25
-
26
-
27
- def fswatch_monitor():
28
- """Monitor fswatch per la piattaforma (SPEC.md §14.2), usato da
29
- `daemon.fswatch_argv()` e dal check monitor di `doctor`."""
30
- return 'fsevents_monitor' if is_darwin() else 'inotify_monitor'
31
-
32
-
33
- def init_system():
34
- """`launchd` (Darwin) o `systemd` (Linux) — SPEC.md §14.3."""
35
- return 'launchd' if is_darwin() else 'systemd'
36
-
37
-
38
- def install_hint():
39
- """Come installare `fswatch` su questa macchina (SPEC.md §14.4)."""
40
- if is_darwin():
41
- return 'brew install fswatch'
42
- for pm, cmd in (('pacman', 'pacman -S fswatch'),
43
- ('apt-get', 'sudo apt-get install fswatch'),
44
- ('dnf', 'sudo dnf install fswatch')):
45
- if shutil.which(pm):
46
- return cmd
47
- return 'installa fswatch col package manager della tua distro'
48
-
49
-
50
- def inotify_limit(path=INOTIFY_LIMIT_PATH):
51
- """`fs.inotify.max_user_watches`, o None se il file non c'è o è illeggibile
52
- (SPEC.md §14.4: su Linux è il rischio #1 sugli alberi grandi)."""
53
- try:
54
- with open(path) as fh:
55
- return int(fh.read().strip())
56
- except (OSError, ValueError):
57
- return None
@@ -1,150 +0,0 @@
1
- """Helper di piattaforma e agent systemd (SPEC.md §14).
2
-
3
- Entrambi i rami (Darwin/Linux) si esercitano patchando `sys.platform`, così la
4
- suite copre il design di portabilità anche girando su una sola piattaforma.
5
- """
6
- import os
7
- import shutil
8
- import subprocess
9
- import tempfile
10
- import unittest
11
- from unittest import mock
12
-
13
- from safekeep import platform as plat
14
-
15
- REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
-
17
-
18
- class MonitorTest(unittest.TestCase):
19
- def test_monitor_dichiarato_per_piattaforma(self):
20
- with mock.patch('sys.platform', 'darwin'):
21
- self.assertEqual(plat.fswatch_monitor(), 'fsevents_monitor')
22
- with mock.patch('sys.platform', 'linux'):
23
- self.assertEqual(plat.fswatch_monitor(), 'inotify_monitor')
24
-
25
- def test_monitor_atteso_e_in_fswatch_M(self):
26
- """Guardrail contro il nome sbagliato: `-m inotify` (senza `_monitor`)
27
- non è un alias accettato da fswatch — qui si verifica che il nome che
28
- passiamo in `-m` sia proprio uno di quelli che `fswatch -M` elenca."""
29
- exe = shutil.which('fswatch')
30
- if exe is None:
31
- self.skipTest('fswatch non installato')
32
- out = subprocess.run([exe, '-M'], capture_output=True, text=True,
33
- timeout=15).stdout
34
- self.assertIn(plat.fswatch_monitor(), out)
35
-
36
-
37
- class InitSystemTest(unittest.TestCase):
38
- def test_init_system_per_piattaforma(self):
39
- with mock.patch('sys.platform', 'darwin'):
40
- self.assertEqual(plat.init_system(), 'launchd')
41
- with mock.patch('sys.platform', 'linux'):
42
- self.assertEqual(plat.init_system(), 'systemd')
43
-
44
- def test_is_darwin_e_is_linux(self):
45
- with mock.patch('sys.platform', 'darwin'):
46
- self.assertTrue(plat.is_darwin())
47
- self.assertFalse(plat.is_linux())
48
- with mock.patch('sys.platform', 'linux'):
49
- self.assertTrue(plat.is_linux())
50
- self.assertFalse(plat.is_darwin())
51
-
52
-
53
- class InstallHintTest(unittest.TestCase):
54
- def test_darwin_usa_brew(self):
55
- with mock.patch('sys.platform', 'darwin'):
56
- self.assertEqual(plat.install_hint(), 'brew install fswatch')
57
-
58
- def test_linux_rileva_il_package_manager(self):
59
- casi = (('pacman', 'pacman -S fswatch'),
60
- ('apt-get', 'sudo apt-get install fswatch'),
61
- ('dnf', 'sudo dnf install fswatch'))
62
- for pm, atteso in casi:
63
- with self.subTest(pm=pm), \
64
- mock.patch('sys.platform', 'linux'), \
65
- mock.patch('shutil.which',
66
- side_effect=lambda name, pm=pm:
67
- '/usr/bin/' + name if name == pm else None):
68
- self.assertEqual(plat.install_hint(), atteso)
69
-
70
- def test_linux_senza_package_manager_noto(self):
71
- with mock.patch('sys.platform', 'linux'), \
72
- mock.patch('shutil.which', return_value=None):
73
- self.assertIn('fswatch', plat.install_hint())
74
-
75
-
76
- class InotifyLimitTest(unittest.TestCase):
77
- """SPEC.md §14.4: check informativo non fatale sul limite di watch."""
78
-
79
- def _fake(self, text):
80
- fd, path = tempfile.mkstemp()
81
- self.addCleanup(os.unlink, path)
82
- with os.fdopen(fd, 'w') as fh:
83
- fh.write(text)
84
- return path
85
-
86
- def test_lettura_da_file_finto(self):
87
- self.assertEqual(plat.inotify_limit(self._fake('524288\n')), 524288)
88
- self.assertEqual(plat.inotify_limit(self._fake('8192')), 8192)
89
-
90
- def test_valore_invalido_o_file_mancante(self):
91
- self.assertIsNone(plat.inotify_limit(self._fake('non-è-un-numbero\n')))
92
- self.assertIsNone(plat.inotify_limit('/proc/assente/inotify'))
93
-
94
- def test_costanti(self):
95
- self.assertEqual(plat.INOTIFY_MIN_WATCHES, 16384)
96
- self.assertEqual(plat.INOTIFY_LIMIT_PATH,
97
- '/proc/sys/fs/inotify/max_user_watches')
98
-
99
-
100
- class PiattaformaCentralizzataTest(unittest.TestCase):
101
- def test_sys_platform_letto_solo_nell_helper(self):
102
- """SPEC.md §14.1: un solo posto legge `sys.platform`, niente check sparsi."""
103
- hits = []
104
- pkg = os.path.join(REPO, 'safekeep')
105
- for name in sorted(os.listdir(pkg)):
106
- if not name.endswith('.py'):
107
- continue
108
- with open(os.path.join(pkg, name), encoding='utf-8') as fh:
109
- for i, line in enumerate(fh, 1):
110
- if 'sys.platform' in line:
111
- hits.append(f'{name}:{i}')
112
- self.assertTrue(hits, 'nessun riferimento a sys.platform trovato')
113
- self.assertTrue(all(h.startswith('platform.py:') for h in hits), hits)
114
-
115
-
116
- class AgentTemplateTest(unittest.TestCase):
117
- """SPEC.md §14.3: la unit systemd è un template con gli stessi placeholder
118
- del plist e copre RunAtLoad/KeepAlive."""
119
-
120
- def test_unit_systemd_template(self):
121
- path = os.path.join(REPO, 'systemd', 'safekeep.service')
122
- with open(path, encoding='utf-8') as fh:
123
- text = fh.read()
124
- for needle in ('ExecStart=__PYTHON__ __REPO__/bin/safekeep.py run',
125
- 'Restart=always',
126
- 'RestartSec=2',
127
- 'StartLimitIntervalSec=0',
128
- 'WantedBy=default.target'):
129
- self.assertIn(needle, text, needle)
130
- self.assertNotIn('/Users/', text, 'path assoluto nel template')
131
-
132
- def test_gli_script_si_ramificano_per_os(self):
133
- for name in ('install.sh', 'uninstall.sh'):
134
- with self.subTest(script=name), \
135
- open(os.path.join(REPO, name), encoding='utf-8') as fh:
136
- text = fh.read()
137
- self.assertIn('uname -s', text, name) # ramo Darwin/Linux
138
- self.assertIn('launchctl', text, name) # ramo macOS invariato
139
- self.assertIn('systemctl --user', text, name) # ramo Linux
140
-
141
- def test_il_plist_macos_non_e_toccato(self):
142
- with open(os.path.join(REPO, 'launchd', 'com.safekeep.agent.plist'),
143
- encoding='utf-8') as fh:
144
- text = fh.read()
145
- for needle in ('RunAtLoad', 'KeepAlive', '__REPO__', '__PYTHON__'):
146
- self.assertIn(needle, text, needle)
147
-
148
-
149
- if __name__ == '__main__':
150
- unittest.main()
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
File without changes
File without changes
File without changes