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.
- {safekeep-0.3.0/safekeep.egg-info → safekeep-0.4.0}/PKG-INFO +1 -1
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/__init__.py +1 -1
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/cli.py +50 -1
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/daemon.py +2 -2
- safekeep-0.4.0/safekeep/platform.py +147 -0
- {safekeep-0.3.0 → safekeep-0.4.0/safekeep.egg-info}/PKG-INFO +1 -1
- {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_cli.py +156 -1
- safekeep-0.4.0/tests/test_platform.py +296 -0
- safekeep-0.3.0/safekeep/platform.py +0 -57
- safekeep-0.3.0/tests/test_platform.py +0 -150
- {safekeep-0.3.0 → safekeep-0.4.0}/LICENSE +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/README.md +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/pyproject.toml +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/config.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/copier.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/matcher.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep/volumes.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/SOURCES.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/setup.cfg +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_config.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_copier.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_daemon.py +0 -0
- {safekeep-0.3.0 → safekeep-0.4.0}/tests/test_matcher.py +0 -0
- {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
|
+
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
|
|
@@ -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),
|
|
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(
|
|
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
|
+
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|