safekeep 0.3.1__tar.gz → 0.5.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.1/safekeep.egg-info → safekeep-0.5.0}/PKG-INFO +6 -5
- {safekeep-0.3.1 → safekeep-0.5.0}/README.md +5 -4
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/__init__.py +1 -1
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/cli.py +64 -8
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/platform.py +31 -3
- {safekeep-0.3.1 → safekeep-0.5.0/safekeep.egg-info}/PKG-INFO +6 -5
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep.egg-info/SOURCES.txt +1 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/tests/test_cli.py +142 -2
- {safekeep-0.3.1 → safekeep-0.5.0}/tests/test_platform.py +65 -0
- safekeep-0.5.0/tests/test_trend.py +80 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/LICENSE +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/pyproject.toml +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/config.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/copier.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/daemon.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/matcher.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep/volumes.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/setup.cfg +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/tests/test_config.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/tests/test_copier.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/tests/test_daemon.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.0}/tests/test_matcher.py +0 -0
- {safekeep-0.3.1 → safekeep-0.5.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.5.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
|
|
@@ -182,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
182
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
183
183
|
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
184
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
185
|
-
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
185
|
+
| `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
|
|
186
186
|
|
|
187
187
|
## Tests
|
|
188
188
|
|
|
@@ -190,9 +190,10 @@ safekeep <command> [--config PATH] [-v]
|
|
|
190
190
|
python3 -m unittest discover -s tests
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
194
|
-
config, atomic copy, volumes, daemon, auto-discovery, CLI
|
|
195
|
-
portability layer (platform helper, per-OS fswatch
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 206 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
195
|
+
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
196
|
+
monitor, systemd template).
|
|
196
197
|
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
198
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
199
|
Linux job exercises the real `inotify` monitor.
|
|
@@ -157,7 +157,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
157
157
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
158
158
|
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
159
159
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
160
|
-
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
160
|
+
| `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
|
|
161
161
|
|
|
162
162
|
## Tests
|
|
163
163
|
|
|
@@ -165,9 +165,10 @@ safekeep <command> [--config PATH] [-v]
|
|
|
165
165
|
python3 -m unittest discover -s tests
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
169
|
-
config, atomic copy, volumes, daemon, auto-discovery, CLI
|
|
170
|
-
portability layer (platform helper, per-OS fswatch
|
|
168
|
+
Stdlib (`unittest`) suite, zero dependencies: 206 tests covering the matcher,
|
|
169
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
170
|
+
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
171
|
+
monitor, systemd template).
|
|
171
172
|
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
172
173
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
173
174
|
Linux job exercises the real `inotify` monitor.
|
|
@@ -4,11 +4,15 @@ Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
|
|
|
4
4
|
shim che usano launchd/systemd e i test.
|
|
5
5
|
"""
|
|
6
6
|
import argparse
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
9
|
+
import re
|
|
8
10
|
import shutil
|
|
9
11
|
import subprocess
|
|
10
12
|
import sys
|
|
13
|
+
from datetime import datetime
|
|
11
14
|
|
|
15
|
+
from safekeep import __version__
|
|
12
16
|
from safekeep.config import ConfigError, discover_projects, parse_sync
|
|
13
17
|
from safekeep.copier import TMP_INFIX
|
|
14
18
|
from safekeep.daemon import Daemon, setup_logging
|
|
@@ -20,8 +24,11 @@ from safekeep.platform import (
|
|
|
20
24
|
install_hint,
|
|
21
25
|
is_darwin,
|
|
22
26
|
is_linux,
|
|
27
|
+
is_wsl,
|
|
23
28
|
linger_enabled,
|
|
24
29
|
recent_permission_errors,
|
|
30
|
+
systemd_user_available,
|
|
31
|
+
wsl_distro,
|
|
25
32
|
)
|
|
26
33
|
from safekeep.volumes import dest_state
|
|
27
34
|
|
|
@@ -55,11 +62,14 @@ def build_parser():
|
|
|
55
62
|
'non è più incluso dal matcher (default: mai cancellare)')
|
|
56
63
|
sub.add_parser('status', parents=[common],
|
|
57
64
|
help='config, progetti e stato delle dest — sola lettura')
|
|
58
|
-
sub.add_parser('doctor', parents=[common],
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
doc = sub.add_parser('doctor', parents=[common],
|
|
66
|
+
help='diagnostica: config, fswatch + monitor di piattaforma, '
|
|
67
|
+
'python, errori di permesso recenti nel log, TCC/plist '
|
|
68
|
+
'(macOS), inotify/systemd/linger (Linux) — exit ≠ 0 se '
|
|
69
|
+
'un check fatale fallisce')
|
|
70
|
+
doc.add_argument('--json', action='store_true',
|
|
71
|
+
help='stessi check, stesso exit code, ma un documento JSON su '
|
|
72
|
+
'stdout (SPEC.md §9.1)')
|
|
63
73
|
return parser
|
|
64
74
|
|
|
65
75
|
|
|
@@ -141,12 +151,28 @@ def check_sync_files(daemon):
|
|
|
141
151
|
|
|
142
152
|
|
|
143
153
|
def cmd_doctor(args):
|
|
144
|
-
"""Un check per riga con esito ✔/✗; exit 1 se almeno un check fatale fallisce.
|
|
154
|
+
"""Un check per riga con esito ✔/✗; exit 1 se almeno un check fatale fallisce.
|
|
155
|
+
|
|
156
|
+
Con `--json` le righe non vengono stampate: la stessa sequenza di check finisce
|
|
157
|
+
in un documento JSON su stdout, con lo stesso exit code (SPEC.md §9.1)."""
|
|
145
158
|
fatal = False
|
|
159
|
+
checks, ids = [], {}
|
|
146
160
|
|
|
147
161
|
def out(ok, msg, is_fatal=False):
|
|
148
162
|
nonlocal fatal
|
|
149
|
-
|
|
163
|
+
if args.json:
|
|
164
|
+
# id = slug del messaggio (prefisso fino a ':' o '('), id uguali
|
|
165
|
+
# ripetuti suffissati _2/_3 così restano chiave usabile (SPEC §9.1)
|
|
166
|
+
base = re.split(r'[:\(]', msg, maxsplit=1)[0]
|
|
167
|
+
ident = re.sub(r'[^a-z0-9]+', '_', base.lower()).strip('_') or 'check'
|
|
168
|
+
ids[ident] = ids.get(ident, 0) + 1
|
|
169
|
+
if ids[ident] > 1:
|
|
170
|
+
ident = f'{ident}_{ids[ident]}'
|
|
171
|
+
checks.append({'id': ident,
|
|
172
|
+
'status': 'ok' if ok else ('fail' if is_fatal else 'warn'),
|
|
173
|
+
'message': msg})
|
|
174
|
+
else:
|
|
175
|
+
print(('✔ ' if ok else '✗ ') + msg)
|
|
150
176
|
if not ok and is_fatal:
|
|
151
177
|
fatal = True
|
|
152
178
|
|
|
@@ -244,6 +270,23 @@ def cmd_doctor(args):
|
|
|
244
270
|
else:
|
|
245
271
|
out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
|
|
246
272
|
|
|
273
|
+
# WSL (SPEC.md §17.2): tre righe informative — nessuna è mai fatale
|
|
274
|
+
if is_wsl():
|
|
275
|
+
out(True, f'WSL rilevato: {wsl_distro() or "sconosciuta"} '
|
|
276
|
+
'(supporto best-effort, SPEC §17)')
|
|
277
|
+
if not systemd_user_available():
|
|
278
|
+
out(False, 'systemd user non disponibile (WSL1 o systemd spento) — '
|
|
279
|
+
'abilita systemd in /etc/wsl.conf: [boot] systemd=true '
|
|
280
|
+
'(poi wsl --shutdown da Windows) oppure esegui '
|
|
281
|
+
'`safekeep run` a mano — warning non fatale')
|
|
282
|
+
if daemon is not None:
|
|
283
|
+
sotto_mnt = [p for p in list(daemon.cfg.sources)
|
|
284
|
+
+ list(daemon.cfg.dests) if p.startswith('/mnt/')]
|
|
285
|
+
if sotto_mnt:
|
|
286
|
+
out(True, f'drvfs: {len(sotto_mnt)} path sotto /mnt/ — I/O lento '
|
|
287
|
+
'e case-insensitive di default: valuta una dest su ext4 '
|
|
288
|
+
'nativa della distro (SPEC §17.2)')
|
|
289
|
+
|
|
247
290
|
# Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
|
|
248
291
|
# (SPEC.md §14.4) — informativo, NON fatale
|
|
249
292
|
if is_linux():
|
|
@@ -326,7 +369,20 @@ def cmd_doctor(args):
|
|
|
326
369
|
'senza login → loginctl enable-linger $USER — '
|
|
327
370
|
'warning non fatale')
|
|
328
371
|
|
|
329
|
-
|
|
372
|
+
code = 1 if fatal else 0
|
|
373
|
+
if args.json:
|
|
374
|
+
count, last = recent if recent is not None else (0, None)
|
|
375
|
+
print(json.dumps({
|
|
376
|
+
'safekeep': __version__,
|
|
377
|
+
'timestamp': datetime.now().isoformat(timespec='seconds'),
|
|
378
|
+
'exit': code,
|
|
379
|
+
'checks': checks,
|
|
380
|
+
'permission_errors': {
|
|
381
|
+
'count_24h': count,
|
|
382
|
+
'last': last.strftime('%Y-%m-%d %H:%M:%S') if last else None,
|
|
383
|
+
},
|
|
384
|
+
}, ensure_ascii=False, indent=2))
|
|
385
|
+
return code
|
|
330
386
|
|
|
331
387
|
|
|
332
388
|
def main(argv=None):
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
|
|
2
2
|
|
|
3
3
|
Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
|
|
4
|
-
init system, limite di watch di inotify, log corrente, linger systemd
|
|
5
|
-
Tutto letto **a chiamata** (mai a import), così i test
|
|
6
|
-
ed esercitano entrambi i rami anche girando su una
|
|
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.
|
|
7
8
|
|
|
8
9
|
I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
|
|
9
10
|
(FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
|
|
@@ -11,6 +12,7 @@ il nome NON è `inotify` (SPEC.md §14.2).
|
|
|
11
12
|
"""
|
|
12
13
|
import getpass
|
|
13
14
|
import os
|
|
15
|
+
import platform as _platform
|
|
14
16
|
import re
|
|
15
17
|
import shutil
|
|
16
18
|
import sys
|
|
@@ -20,6 +22,7 @@ INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
|
|
|
20
22
|
INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
|
|
21
23
|
LOG_PATH = '~/.local/state/safekeep/safekeep.log' # log corrente (SPEC.md §3)
|
|
22
24
|
LINGER_DIR = '/var/lib/systemd/linger'
|
|
25
|
+
SYSTEMD_RUN_PATH = '/run/systemd/system' # assente su WSL1/systemd off
|
|
23
26
|
# EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
|
|
24
27
|
# + testo del kernel (SPEC.md §14.4)
|
|
25
28
|
PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
|
|
@@ -117,3 +120,28 @@ def linger_enabled(user=None, linger_dir=None):
|
|
|
117
120
|
except OSError:
|
|
118
121
|
return None
|
|
119
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.5.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
|
|
@@ -182,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
182
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
183
183
|
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
184
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
185
|
-
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
185
|
+
| `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
|
|
186
186
|
|
|
187
187
|
## Tests
|
|
188
188
|
|
|
@@ -190,9 +190,10 @@ safekeep <command> [--config PATH] [-v]
|
|
|
190
190
|
python3 -m unittest discover -s tests
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
194
|
-
config, atomic copy, volumes, daemon, auto-discovery, CLI
|
|
195
|
-
portability layer (platform helper, per-OS fswatch
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 206 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
195
|
+
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
196
|
+
monitor, systemd template).
|
|
196
197
|
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
198
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
199
|
Linux job exercises the real `inotify` monitor.
|
|
@@ -6,6 +6,7 @@ import argparse
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import getpass
|
|
8
8
|
import io
|
|
9
|
+
import json
|
|
9
10
|
import os
|
|
10
11
|
import shutil
|
|
11
12
|
import subprocess
|
|
@@ -110,7 +111,7 @@ class DoctorTest(CliTestCase):
|
|
|
110
111
|
"""SPEC.md §14.4: su Linux il limite di watch basso è informativo —
|
|
111
112
|
doctor esce 0, non 1. I rami Linux si esercitano patchando l'helper,
|
|
112
113
|
senza girare su Linux."""
|
|
113
|
-
args = argparse.Namespace(config=self.cfg, v=False)
|
|
114
|
+
args = argparse.Namespace(config=self.cfg, v=False, json=False)
|
|
114
115
|
buf = io.StringIO()
|
|
115
116
|
with mock.patch.object(cli, 'is_linux', return_value=True), \
|
|
116
117
|
mock.patch.object(cli, 'is_darwin', return_value=False), \
|
|
@@ -329,7 +330,7 @@ class DoctorLingerTest(CliTestCase):
|
|
|
329
330
|
self.addCleanup(shutil.rmtree, linger_dir, ignore_errors=True)
|
|
330
331
|
for name in linger:
|
|
331
332
|
open(os.path.join(linger_dir, name), 'w').close()
|
|
332
|
-
args = argparse.Namespace(config=self.cfg, v=False)
|
|
333
|
+
args = argparse.Namespace(config=self.cfg, v=False, json=False)
|
|
333
334
|
buf = io.StringIO()
|
|
334
335
|
with mock.patch.dict(os.environ, {'HOME': self.home}), \
|
|
335
336
|
mock.patch.object(cli, 'is_linux', return_value=True), \
|
|
@@ -363,5 +364,144 @@ class DoctorLingerTest(CliTestCase):
|
|
|
363
364
|
self.assertEqual(code, 0, out)
|
|
364
365
|
|
|
365
366
|
|
|
367
|
+
class DoctorWslTest(CliTestCase):
|
|
368
|
+
"""SPEC.md §17.2: le tre righe WSL di `doctor` sono informative e non fatali."""
|
|
369
|
+
|
|
370
|
+
def doctor_wsl(self, distro='Ubuntu', systemd=False, dest='/mnt/c/backup'):
|
|
371
|
+
cfg = write(os.path.join(self.tmp, 'wsl.cfg'), f'dest: {dest}\n')
|
|
372
|
+
args = argparse.Namespace(config=cfg, v=False, json=False)
|
|
373
|
+
buf = io.StringIO()
|
|
374
|
+
with mock.patch.dict(os.environ, {'HOME': self.home}), \
|
|
375
|
+
mock.patch.object(cli, 'is_wsl', return_value=True), \
|
|
376
|
+
mock.patch.object(cli, 'wsl_distro', return_value=distro), \
|
|
377
|
+
mock.patch.object(cli, 'systemd_user_available',
|
|
378
|
+
return_value=systemd), \
|
|
379
|
+
contextlib.redirect_stdout(buf):
|
|
380
|
+
code = cli.cmd_doctor(args)
|
|
381
|
+
return code, buf.getvalue()
|
|
382
|
+
|
|
383
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
384
|
+
def test_wsle_righe_con_hint_systemd_e_drvfs(self):
|
|
385
|
+
code, out = self.doctor_wsl()
|
|
386
|
+
self.assertIn('✔ WSL rilevato: Ubuntu', out)
|
|
387
|
+
hint = next(l for l in out.splitlines()
|
|
388
|
+
if 'systemd user non disponibile' in l)
|
|
389
|
+
self.assertTrue(hint.startswith('✗'), hint)
|
|
390
|
+
for needle in ('/etc/wsl.conf', 'systemd=true', 'safekeep run',
|
|
391
|
+
'warning non fatale'):
|
|
392
|
+
self.assertIn(needle, hint, needle)
|
|
393
|
+
drvfs = next(l for l in out.splitlines() if 'drvfs' in l)
|
|
394
|
+
self.assertIn('/mnt/', drvfs)
|
|
395
|
+
self.assertEqual(code, 0, out) # mai fatale
|
|
396
|
+
|
|
397
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
398
|
+
def test_systemd_attivo_nessun_hint(self):
|
|
399
|
+
code, out = self.doctor_wsl(systemd=True)
|
|
400
|
+
self.assertNotIn('systemd user non disponibile', out)
|
|
401
|
+
self.assertIn('WSL rilevato', out)
|
|
402
|
+
self.assertEqual(code, 0, out)
|
|
403
|
+
|
|
404
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
405
|
+
def test_nessun_path_sotto_mnt_niente_drvfs(self):
|
|
406
|
+
code, out = self.doctor_wsl(dest=os.path.join(self.tmp, 'dst'))
|
|
407
|
+
self.assertNotIn('drvfs', out)
|
|
408
|
+
self.assertEqual(code, 0, out)
|
|
409
|
+
|
|
410
|
+
def test_detection_reale_via_env_fino_a_doctor(self):
|
|
411
|
+
"""Detection reale (env) fino all'output di doctor: niente patch."""
|
|
412
|
+
env = dict(os.environ, HOME=self.home, WSL_DISTRO_NAME='Debian')
|
|
413
|
+
r = subprocess.run([sys.executable, CLI, 'doctor', '--config', self.cfg],
|
|
414
|
+
capture_output=True, text=True, env=env, cwd=REPO,
|
|
415
|
+
timeout=60)
|
|
416
|
+
self.assertIn('WSL rilevato: Debian', r.stdout)
|
|
417
|
+
|
|
418
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
419
|
+
def test_senza_wsl_nulla_cambia(self):
|
|
420
|
+
"""Su Linux/macOS non-WSL nessuna riga WSL compare (SPEC §17)."""
|
|
421
|
+
env = {k: v for k, v in os.environ.items() if k != 'WSL_DISTRO_NAME'}
|
|
422
|
+
env['HOME'] = self.home
|
|
423
|
+
r = subprocess.run([sys.executable, CLI, 'doctor', '--config', self.cfg],
|
|
424
|
+
capture_output=True, text=True, env=env, cwd=REPO,
|
|
425
|
+
timeout=60)
|
|
426
|
+
self.assertNotIn('WSL', r.stdout)
|
|
427
|
+
self.assertNotIn('drvfs', r.stdout)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class DoctorJsonTest(CliTestCase):
|
|
431
|
+
"""SPEC.md §9.1: `doctor --json` — stessi check, stesso ordine, stesso exit
|
|
432
|
+
code della modalità normale, tutto in un documento JSON su stdout."""
|
|
433
|
+
|
|
434
|
+
def json_doctor(self, *args):
|
|
435
|
+
r = self.cli('doctor', '--json', *args)
|
|
436
|
+
# json.loads sul stdout intero: basta una riga di log mista e cade qui
|
|
437
|
+
return r, json.loads(r.stdout)
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def righe(stdout):
|
|
441
|
+
"""`(status_atteso, message)` di ogni riga della modalità normale."""
|
|
442
|
+
out = []
|
|
443
|
+
for line in stdout.splitlines():
|
|
444
|
+
if not line.strip():
|
|
445
|
+
continue
|
|
446
|
+
status = 'ok' if line.startswith('✔') else 'fail-o-warn'
|
|
447
|
+
out.append((status, line[2:]))
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
def test_checks_1a1_con_la_modalita_normale(self):
|
|
451
|
+
normale = self.cli('doctor', '--config', self.cfg)
|
|
452
|
+
r, doc = self.json_doctor('--config', self.cfg)
|
|
453
|
+
attese = self.righe(normale.stdout)
|
|
454
|
+
self.assertEqual([c['message'] for c in doc['checks']],
|
|
455
|
+
[msg for _, msg in attese],
|
|
456
|
+
'stessi check, stesso ordine della modalità normale')
|
|
457
|
+
for (atteso, _), check in zip(attese, doc['checks']):
|
|
458
|
+
if atteso == 'ok':
|
|
459
|
+
self.assertEqual(check['status'], 'ok', check)
|
|
460
|
+
else: # riga ✗: warn o fail
|
|
461
|
+
self.assertIn(check['status'], ('warn', 'fail'), check)
|
|
462
|
+
self.assertEqual(len({c['id'] for c in doc['checks']}),
|
|
463
|
+
len(doc['checks']), 'id duplicati')
|
|
464
|
+
self.assertEqual(r.returncode, normale.returncode, 'stesso exit code')
|
|
465
|
+
self.assertEqual(doc['exit'], r.returncode)
|
|
466
|
+
|
|
467
|
+
def test_header_e_campi_decisionali(self):
|
|
468
|
+
_, doc = self.json_doctor('--config', self.cfg)
|
|
469
|
+
self.assertEqual(doc['safekeep'], cli.__version__)
|
|
470
|
+
datetime.fromisoformat(doc['timestamp']) # ISO 8601 valido
|
|
471
|
+
self.assertIsInstance(doc['checks'], list)
|
|
472
|
+
self.assertTrue(doc['checks'])
|
|
473
|
+
for check in doc['checks']:
|
|
474
|
+
self.assertEqual(set(check), {'id', 'status', 'message'})
|
|
475
|
+
self.assertIn(check['status'], ('ok', 'warn', 'fail'))
|
|
476
|
+
self.assertEqual(set(doc['permission_errors']), {'count_24h', 'last'})
|
|
477
|
+
|
|
478
|
+
def test_permission_errors_strutturati_sul_log_recente(self):
|
|
479
|
+
log = os.path.join(self.home, '.local/state/safekeep/safekeep.log')
|
|
480
|
+
stamp = (datetime.now() - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')
|
|
481
|
+
write(log, f'{stamp},000 ERROR copia fallita: [Errno 1] Operation not permitted\n')
|
|
482
|
+
r, doc = self.json_doctor('--config', self.cfg)
|
|
483
|
+
perm = doc['permission_errors']
|
|
484
|
+
self.assertEqual(perm['count_24h'], 1)
|
|
485
|
+
self.assertEqual(perm['last'], stamp) # ultimo timestamp
|
|
486
|
+
check = next(c for c in doc['checks']
|
|
487
|
+
if 'errori di permesso' in c['message'])
|
|
488
|
+
self.assertEqual(check['status'], 'warn') # warning, NON fatale
|
|
489
|
+
self.assertEqual(r.returncode, 0, r.stdout)
|
|
490
|
+
|
|
491
|
+
def test_log_assente_count_zero_last_null(self):
|
|
492
|
+
_, doc = self.json_doctor('--config', self.cfg) # HOME finto: niente log
|
|
493
|
+
self.assertEqual(doc['permission_errors'],
|
|
494
|
+
{'count_24h': 0, 'last': None})
|
|
495
|
+
|
|
496
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
497
|
+
def test_check_fatale_esce_1_e_lo_dichiara(self):
|
|
498
|
+
bad = write(os.path.join(self.tmp, 'bad.cfg'), 'riga nuda invalida\n')
|
|
499
|
+
r, doc = self.json_doctor('--config', bad)
|
|
500
|
+
self.assertEqual(r.returncode, 1, r.stdout)
|
|
501
|
+
self.assertEqual(doc['exit'], 1)
|
|
502
|
+
self.assertTrue(any(c['status'] == 'fail' for c in doc['checks']),
|
|
503
|
+
'il check fatale deve comparire come fail')
|
|
504
|
+
|
|
505
|
+
|
|
366
506
|
if __name__ == '__main__':
|
|
367
507
|
unittest.main()
|
|
@@ -227,5 +227,70 @@ class LingerTest(unittest.TestCase):
|
|
|
227
227
|
self.assertFalse(plat.linger_enabled(linger_dir=self._dir()))
|
|
228
228
|
|
|
229
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
|
+
|
|
230
295
|
if __name__ == '__main__':
|
|
231
296
|
unittest.main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SPEC.md §9.2: `bin/safekeep-trend.sh` — wrapper trend errori di permesso.
|
|
2
|
+
|
|
3
|
+
Ogni run è un subprocess con HOME finto: niente scritture nella home reale e
|
|
4
|
+
niente tocco ai log reali. Copre: header scritto una sola volta (idempotente),
|
|
5
|
+
riga parseabile a 4 campi, riga appendata anche quando `doctor` esce 1, override
|
|
6
|
+
del path CSV via env.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
TREND = os.path.join(REPO, 'bin', 'safekeep-trend.sh')
|
|
16
|
+
HEADER = 'timestamp,exit,count_24h,last'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@unittest.skipUnless(shutil.which('bash'), 'bash non disponibile')
|
|
20
|
+
class TrendTestCase(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.tmp = tempfile.mkdtemp()
|
|
23
|
+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
|
24
|
+
self.home = os.path.join(self.tmp, 'home') # HOME finto
|
|
25
|
+
os.makedirs(self.home)
|
|
26
|
+
self.csv = os.path.join(self.home, '.local', 'state', 'safekeep',
|
|
27
|
+
'permission-trend.csv')
|
|
28
|
+
|
|
29
|
+
def trend(self, *args, csv=None):
|
|
30
|
+
env = dict(os.environ, HOME=self.home)
|
|
31
|
+
if csv is not None:
|
|
32
|
+
env['SAFEKEEP_TREND_CSV'] = csv
|
|
33
|
+
return subprocess.run(['bash', TREND, *args], capture_output=True,
|
|
34
|
+
text=True, env=env, cwd=REPO, timeout=120)
|
|
35
|
+
|
|
36
|
+
def lines(self, path=None):
|
|
37
|
+
with open(path or self.csv, encoding='utf-8') as fh:
|
|
38
|
+
return fh.read().splitlines()
|
|
39
|
+
|
|
40
|
+
def test_header_e_riga_parseabile(self):
|
|
41
|
+
r = self.trend()
|
|
42
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
43
|
+
lines = self.lines()
|
|
44
|
+
self.assertEqual(lines[0], HEADER)
|
|
45
|
+
self.assertEqual(len(lines), 2) # header + 1 run
|
|
46
|
+
row = lines[1].split(',')
|
|
47
|
+
self.assertEqual(len(row), 4) # split campi, niente quoting
|
|
48
|
+
self.assertEqual(row[1], '0') # exit di doctor (JSON §9.1)
|
|
49
|
+
self.assertEqual(int(row[2]), int(row[2])) # count_24h intero
|
|
50
|
+
self.assertTrue(row[0].startswith('20')) # timestamp ISO 8601
|
|
51
|
+
|
|
52
|
+
def test_run_multipla_non_duplica_header(self):
|
|
53
|
+
self.assertEqual(self.trend().returncode, 0)
|
|
54
|
+
self.assertEqual(self.trend().returncode, 0)
|
|
55
|
+
lines = self.lines()
|
|
56
|
+
self.assertEqual(len(lines), 3) # header + 2 run
|
|
57
|
+
self.assertEqual(lines.count(HEADER), 1) # header una sola volta
|
|
58
|
+
self.assertEqual(lines[0], HEADER)
|
|
59
|
+
|
|
60
|
+
def test_doctor_exit_1_genera_comunque_riga(self):
|
|
61
|
+
# config inesistente passata esplicitamente → check fatale → exit 1,
|
|
62
|
+
# ma l'exit code è un dato: la riga va scritta (SPEC §9.2)
|
|
63
|
+
missing = os.path.join(self.tmp, 'non-esiste.cfg')
|
|
64
|
+
r = self.trend('--config', missing)
|
|
65
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
66
|
+
row = self.lines()[1].split(',')
|
|
67
|
+
self.assertEqual(row[1], '1')
|
|
68
|
+
self.assertEqual(int(row[2]), int(row[2]))
|
|
69
|
+
|
|
70
|
+
def test_override_csv_path(self):
|
|
71
|
+
alt = os.path.join(self.tmp, 'alt', 'trend.csv')
|
|
72
|
+
r = self.trend(csv=alt)
|
|
73
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
74
|
+
self.assertTrue(os.path.exists(alt))
|
|
75
|
+
self.assertFalse(os.path.exists(self.csv)) # default non toccato
|
|
76
|
+
self.assertEqual(self.lines(alt)[0], HEADER)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == '__main__':
|
|
80
|
+
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
|