dosync 0.4.2__tar.gz → 0.4.3__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.
- {dosync-0.4.2 → dosync-0.4.3}/PKG-INFO +1 -1
- {dosync-0.4.2 → dosync-0.4.3}/dosync/__init__.py +1 -1
- {dosync-0.4.2 → dosync-0.4.3}/dosync/cli.py +18 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/declarative.py +2 -1
- {dosync-0.4.2 → dosync-0.4.3}/dosync/hub.py +8 -3
- dosync-0.4.3/dosync/paths.py +224 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/policy_config.py +3 -1
- {dosync-0.4.2 → dosync-0.4.3}/dosync/security.py +2 -1
- {dosync-0.4.2 → dosync-0.4.3}/dosync/server.py +8 -2
- {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/PKG-INFO +1 -1
- {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/SOURCES.txt +2 -0
- dosync-0.4.3/tests/test_deployment_layout.py +209 -0
- {dosync-0.4.2 → dosync-0.4.3}/LICENSE +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/README.md +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/__init__.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/ble.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/declarative.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/homeassistant.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/matter.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/mavlink.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/mqtt.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/notifications.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/shelly.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/adapters/wiz.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/audit_backup.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/auth.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/auth_fastapi.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/cert_signing.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/certify.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/composite_operations.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/config_reference.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/dashboard.html +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/db.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/device_arbiter.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/discovery.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/ed25519_pure.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/__init__.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/3d-printer.yaml +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/air-conditioner.yaml +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/building-lighting.json +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/light-generic.yaml +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/examples/declarative/television.yaml +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/executor.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/geo.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/hub_monitor.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/lightweight.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/manage.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/mcp_server.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/metrics.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/models.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/operation_guards.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/operation_supervisor.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/operations.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/plugins.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/policies.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/py.typed +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/reconciler.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/route_composer.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/spec_coverage.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync/validation.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/dependency_links.txt +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/entry_points.txt +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/requires.txt +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/dosync.egg-info/top_level.txt +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/pyproject.toml +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/setup.cfg +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_adapters.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_archive.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_backup.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_chain_integrity.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_audit_provenance.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_auth.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_ble_adapter.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_certification_honesty.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_claim_state_machine.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_composite_operations.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_composite_orchestration.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_composition_kind_db.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_composition_kind_endpoint.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_composition_routing.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_db.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_declarative_adapters.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_declarative_quarantine.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_deployment_env_contract.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_device_health.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_device_heartbeat.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_direct_action_governance.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_discovery_adoption.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_drone_policies.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_ed25519_pure.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_emergency_preemption.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_event_loop_migration.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_explain_consistency.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_geo.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_ha_bridge_hygiene.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_hub_monitor.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_idempotency.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_independent_observation.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_integration_suite.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_lightweight_heartbeat.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_adapter.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_channels.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_listener.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_return_home.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_single_reader.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mavlink_telemetry_closure.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mcp_dynamic_intents.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_mcp_partial_progress.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_metrics.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_models.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_multihub_endpoints.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_operation_guards.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_operation_supervisor.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations_endpoints.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations_persistence.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_operations_wiring.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_panel_polish_2026_07_21.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_policies.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_policy_config.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_pushed_verification.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_reachability_cause.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_recall_benchmark_postpolicy.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_reconciler.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_resolution_wiring.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_resolver_scoring.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_resolver_semantics.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_route_composer.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_sensor_kind.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_server.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_telemetry_bridge.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_third_party_adapters.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_validation.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_validation_integration.py +0 -0
- {dosync-0.4.2 → dosync-0.4.3}/tests/test_wiring_audit.py +0 -0
|
@@ -53,6 +53,24 @@ def hub(argv=None) -> None:
|
|
|
53
53
|
os.environ["DOSYNC_PORT"] = str(args.port)
|
|
54
54
|
os.environ["DOSYNC_HOST"] = args.host
|
|
55
55
|
|
|
56
|
+
# Binding to loopback is the right default — a hub reachable from the whole
|
|
57
|
+
# network because nobody chose that is worse than one that needs a flag. But
|
|
58
|
+
# the commonest deployment is a headless Raspberry Pi whose operator is on
|
|
59
|
+
# SSH and wants the dashboard from their laptop, and "Uvicorn running on
|
|
60
|
+
# http://127.0.0.1" does not tell them why their browser cannot connect.
|
|
61
|
+
#
|
|
62
|
+
# Found by installing from PyPI on a clean machine and trying to open the
|
|
63
|
+
# dashboard from another one. The default does not change; the silence does.
|
|
64
|
+
if args.host in ("127.0.0.1", "localhost", "::1"):
|
|
65
|
+
print()
|
|
66
|
+
print(" Listening on loopback only — reachable from this machine at")
|
|
67
|
+
print(f" http://localhost:{args.port}")
|
|
68
|
+
print(" To reach it from another machine on your network:")
|
|
69
|
+
print(f" dosync-hub --host 0.0.0.0 --port {args.port}")
|
|
70
|
+
print(" (that exposes the hub to your local network; it keeps requiring")
|
|
71
|
+
print(" a token, and TLS is a separate step — see setup_pki.sh)")
|
|
72
|
+
print()
|
|
73
|
+
|
|
56
74
|
uvicorn.run("dosync.server:app", host=args.host, port=args.port,
|
|
57
75
|
reload=args.reload, log_level=args.log_level)
|
|
58
76
|
|
|
@@ -281,7 +281,8 @@ def load_directory(directory: str = None) -> list[tuple[Any, dict]]:
|
|
|
281
281
|
the hub running to fix it.
|
|
282
282
|
"""
|
|
283
283
|
if directory is None:
|
|
284
|
-
|
|
284
|
+
from .paths import resolve_config_dir
|
|
285
|
+
directory = str(resolve_config_dir("declarative", "DOSYNC_DECLARATIVE_DIR"))
|
|
285
286
|
path = Path(directory)
|
|
286
287
|
if not path.is_dir():
|
|
287
288
|
return []
|
|
@@ -1639,7 +1639,9 @@ class DoSyncHub:
|
|
|
1639
1639
|
"A rewritten history will not be detectable.", interval)
|
|
1640
1640
|
return
|
|
1641
1641
|
if directory is None:
|
|
1642
|
-
|
|
1642
|
+
from .paths import resolve_state
|
|
1643
|
+
directory = str(resolve_state("checkpoints", "DOSYNC_CHECKPOINT_DIR",
|
|
1644
|
+
create=True))
|
|
1643
1645
|
|
|
1644
1646
|
_export = os.environ.get("DOSYNC_CHECKPOINT_EXPORT_DIR")
|
|
1645
1647
|
log.info("Audit checkpoints scheduled every %.0fs → %s", interval, directory)
|
|
@@ -1699,7 +1701,9 @@ class DoSyncHub:
|
|
|
1699
1701
|
from . import audit_backup, cert_signing
|
|
1700
1702
|
|
|
1701
1703
|
if directory is None:
|
|
1702
|
-
|
|
1704
|
+
from .paths import resolve_state
|
|
1705
|
+
directory = str(resolve_state("checkpoints", "DOSYNC_CHECKPOINT_DIR",
|
|
1706
|
+
create=True))
|
|
1703
1707
|
|
|
1704
1708
|
# The mark must be current before attesting to it.
|
|
1705
1709
|
self.audit_log.flush_head()
|
|
@@ -1832,7 +1836,8 @@ class DoSyncHub:
|
|
|
1832
1836
|
if keep <= 0:
|
|
1833
1837
|
return None
|
|
1834
1838
|
if directory is None:
|
|
1835
|
-
|
|
1839
|
+
from .paths import resolve_state
|
|
1840
|
+
directory = str(resolve_state("audit-segments", "DOSYNC_ARCHIVE_DIR", create=True))
|
|
1836
1841
|
|
|
1837
1842
|
entries = self.audit_log.entries()
|
|
1838
1843
|
if len(entries) <= keep:
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Where a DoSync deployment keeps its configuration and its state.
|
|
2
|
+
|
|
3
|
+
Found while preparing to reflash the reference deployment: its configuration
|
|
4
|
+
lived in nine places, four of which the author did not remember existed, and
|
|
5
|
+
three of those were **inside a git clone**. A `git clean -fdx` — a command people
|
|
6
|
+
run to tidy a repository — would have destroyed a 42,000-entry audit chain and
|
|
7
|
+
the CA's private key.
|
|
8
|
+
|
|
9
|
+
That is worth stating plainly, because it is a contradiction: this protocol
|
|
10
|
+
argues that its evidence survives someone with root access, and until now it did
|
|
11
|
+
not survive someone tidying up. The sophisticated threat was covered and the
|
|
12
|
+
trivial one was not.
|
|
13
|
+
|
|
14
|
+
Two modes, because there are two ways to install
|
|
15
|
+
------------------------------------------------
|
|
16
|
+
`pipx install dosync` runs as an ordinary user, who cannot write to `/etc` or
|
|
17
|
+
`/var/lib`. A systemd service running as root can and should. Rather than pick
|
|
18
|
+
one and break the other, paths cascade: the user location first, the system
|
|
19
|
+
location second. The same binary serves both, and neither needs configuration to
|
|
20
|
+
work.
|
|
21
|
+
|
|
22
|
+
Configuration ~/.config/dosync/ → /etc/dosync/
|
|
23
|
+
State ~/.local/state/dosync/ → /var/lib/dosync/
|
|
24
|
+
PKI <state>/certs/ (mode 0700)
|
|
25
|
+
|
|
26
|
+
The split follows the XDG categories, and the deciding question is the one that
|
|
27
|
+
specification asks: *is this datum unique to this machine?* An audit chain and a
|
|
28
|
+
private CA are — they are state. Policies and declarative device files are not;
|
|
29
|
+
they are edited by hand and can be copied between machines, so they are
|
|
30
|
+
configuration.
|
|
31
|
+
|
|
32
|
+
Compatibility is not optional here
|
|
33
|
+
----------------------------------
|
|
34
|
+
A deployment exists with 42,000 entries and certificates in use. If the hub
|
|
35
|
+
stopped looking where that deployment keeps its data, it would come up tomorrow
|
|
36
|
+
with an empty chain and a fresh CA — and the history this project spent weeks
|
|
37
|
+
protecting would be orphaned in a directory nobody looks at.
|
|
38
|
+
|
|
39
|
+
So: an explicit variable always wins, an existing database in the working
|
|
40
|
+
directory keeps being used with a warning explaining how to move it, and finding
|
|
41
|
+
data in two places is an error rather than a choice. Choosing wrong there would
|
|
42
|
+
mean writing to one chain while auditing another.
|
|
43
|
+
"""
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger("dosync.paths")
|
|
49
|
+
|
|
50
|
+
#: Set by the packaging or the operator to force system mode. Without it, the
|
|
51
|
+
#: mode is inferred: root implies a system service.
|
|
52
|
+
MODE_ENV = "DOSYNC_INSTALL_MODE"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _running_as_root() -> bool:
|
|
56
|
+
try:
|
|
57
|
+
return os.geteuid() == 0
|
|
58
|
+
except AttributeError: # pragma: no cover - non-POSIX
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install_mode() -> str:
|
|
63
|
+
"""`"system"` or `"user"`.
|
|
64
|
+
|
|
65
|
+
Inferred from the effective user rather than declared, because the common
|
|
66
|
+
cases decide themselves: a systemd unit runs as root, `pipx` does not.
|
|
67
|
+
`DOSYNC_INSTALL_MODE` overrides for the cases that do not — a service
|
|
68
|
+
running under a dedicated unprivileged account, for instance.
|
|
69
|
+
"""
|
|
70
|
+
declared = os.environ.get(MODE_ENV, "").strip().lower()
|
|
71
|
+
if declared in ("system", "user"):
|
|
72
|
+
return declared
|
|
73
|
+
return "system" if _running_as_root() else "user"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def config_dirs() -> list[Path]:
|
|
77
|
+
"""Where configuration is looked for, in order of preference."""
|
|
78
|
+
if install_mode() == "system":
|
|
79
|
+
return [Path("/etc/dosync")]
|
|
80
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
81
|
+
home = Path(xdg) if xdg else Path.home() / ".config"
|
|
82
|
+
return [home / "dosync", Path("/etc/dosync")]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def state_dir() -> Path:
|
|
86
|
+
"""Where this deployment's own data lives — chain, PKI, evidence."""
|
|
87
|
+
if install_mode() == "system":
|
|
88
|
+
return Path("/var/lib/dosync")
|
|
89
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
90
|
+
home = Path(xdg) if xdg else Path.home() / ".local" / "state"
|
|
91
|
+
return home / "dosync"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _legacy(name: str) -> Path:
|
|
95
|
+
"""The pre-0.4.3 location: relative to the working directory."""
|
|
96
|
+
return Path.cwd() / name
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_state(name: str, env_var: str = None, create: bool = False) -> Path:
|
|
100
|
+
"""Resolve a state path, honouring an existing deployment above all else.
|
|
101
|
+
|
|
102
|
+
Order:
|
|
103
|
+
1. `env_var`, if set — an operator who said where wins, always.
|
|
104
|
+
2. The legacy path, **if it already has data**. A running deployment does
|
|
105
|
+
not lose its chain to an upgrade.
|
|
106
|
+
3. The state directory.
|
|
107
|
+
|
|
108
|
+
Finding data in both the legacy path and the state directory raises, rather
|
|
109
|
+
than picking. Picking wrong means writing to one chain and auditing another,
|
|
110
|
+
and the operator is the only one who knows which is current.
|
|
111
|
+
"""
|
|
112
|
+
if env_var:
|
|
113
|
+
explicit = os.environ.get(env_var)
|
|
114
|
+
if explicit:
|
|
115
|
+
return Path(explicit)
|
|
116
|
+
|
|
117
|
+
legacy, modern = _legacy(name), state_dir() / name
|
|
118
|
+
legacy_has = legacy.exists() and (legacy.is_file() or any(legacy.iterdir()))
|
|
119
|
+
modern_has = modern.exists() and (modern.is_file() or any(modern.iterdir()))
|
|
120
|
+
|
|
121
|
+
if legacy_has and modern_has:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"'{name}' exists in two places and DoSync will not guess which is "
|
|
124
|
+
f"current:\n {legacy}\n {modern}\n"
|
|
125
|
+
f"Keep one, or set {env_var or 'the matching DOSYNC_* variable'} to "
|
|
126
|
+
f"say which. Choosing wrong here would mean writing to one and "
|
|
127
|
+
f"auditing the other.")
|
|
128
|
+
|
|
129
|
+
if legacy_has:
|
|
130
|
+
log.warning(
|
|
131
|
+
"Using %s from the working directory. Since 0.4.3 this belongs in "
|
|
132
|
+
"%s — data inside a source tree is one `git clean` from being gone. "
|
|
133
|
+
"Move it there, or set %s to keep it where it is.",
|
|
134
|
+
name, modern, env_var or "the matching variable")
|
|
135
|
+
return legacy
|
|
136
|
+
|
|
137
|
+
if create:
|
|
138
|
+
modern.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
return modern
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_config(name: str, env_var: str = None) -> Path | None:
|
|
143
|
+
"""Find a configuration file, or None if there is none.
|
|
144
|
+
|
|
145
|
+
Returns None rather than a non-existent path: absent configuration is a
|
|
146
|
+
normal state for this protocol — a hub with no deployment policies is
|
|
147
|
+
conforming — and a caller should not have to distinguish "missing file" from
|
|
148
|
+
"file that happens not to exist yet".
|
|
149
|
+
"""
|
|
150
|
+
if env_var:
|
|
151
|
+
explicit = os.environ.get(env_var)
|
|
152
|
+
if explicit:
|
|
153
|
+
return Path(explicit)
|
|
154
|
+
|
|
155
|
+
legacy = _legacy(name)
|
|
156
|
+
if legacy.exists():
|
|
157
|
+
return legacy
|
|
158
|
+
for d in config_dirs():
|
|
159
|
+
candidate = d / name
|
|
160
|
+
if candidate.exists():
|
|
161
|
+
return candidate
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_config_dir(name: str, env_var: str = None) -> Path:
|
|
166
|
+
"""A configuration DIRECTORY — declarative device files, for instance.
|
|
167
|
+
|
|
168
|
+
Unlike `resolve_config`, always returns a path: the caller iterates it and
|
|
169
|
+
an empty or absent directory is a legitimate answer meaning "no declarative
|
|
170
|
+
devices", not an error.
|
|
171
|
+
"""
|
|
172
|
+
if env_var:
|
|
173
|
+
explicit = os.environ.get(env_var)
|
|
174
|
+
if explicit:
|
|
175
|
+
return Path(explicit)
|
|
176
|
+
|
|
177
|
+
legacy = _legacy(name)
|
|
178
|
+
if legacy.is_dir() and any(legacy.iterdir()):
|
|
179
|
+
return legacy
|
|
180
|
+
for d in config_dirs():
|
|
181
|
+
candidate = d / name
|
|
182
|
+
if candidate.is_dir():
|
|
183
|
+
return candidate
|
|
184
|
+
return config_dirs()[0] / name
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def certs_dir() -> Path:
|
|
188
|
+
"""The PKI directory, created 0700 when this call creates it.
|
|
189
|
+
|
|
190
|
+
A private key in a directory with whatever permissions it inherited is the
|
|
191
|
+
kind of detail that is invisible until an audit asks about it.
|
|
192
|
+
"""
|
|
193
|
+
explicit = os.environ.get("DOSYNC_CERTS_DIR")
|
|
194
|
+
if explicit:
|
|
195
|
+
return Path(explicit)
|
|
196
|
+
|
|
197
|
+
legacy = _legacy("certs")
|
|
198
|
+
if legacy.exists() and any(legacy.iterdir()):
|
|
199
|
+
return legacy
|
|
200
|
+
|
|
201
|
+
d = state_dir() / "certs"
|
|
202
|
+
if not d.exists():
|
|
203
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
try:
|
|
205
|
+
d.chmod(0o700)
|
|
206
|
+
except OSError as e: # pragma: no cover - exotic filesystems
|
|
207
|
+
log.warning("Could not restrict permissions on %s: %s", d, e)
|
|
208
|
+
return d
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def describe() -> dict:
|
|
212
|
+
"""Every path this deployment resolved, for the startup log and /v1/status.
|
|
213
|
+
|
|
214
|
+
Reported because an operator editing `/etc/dosync/policies.json` while the
|
|
215
|
+
hub reads `~/.config/dosync/policies.json` believes they are protected by a
|
|
216
|
+
policy the hub never loaded. Silent divergence between what someone edits
|
|
217
|
+
and what runs is exactly the failure this project keeps finding.
|
|
218
|
+
"""
|
|
219
|
+
return {
|
|
220
|
+
"mode": install_mode(),
|
|
221
|
+
"config_dirs": [str(d) for d in config_dirs()],
|
|
222
|
+
"state_dir": str(state_dir()),
|
|
223
|
+
"certs_dir": str(certs_dir()),
|
|
224
|
+
}
|
|
@@ -249,4 +249,6 @@ def configured_path() -> str | None:
|
|
|
249
249
|
common state — not an error. The reference hub ships with NO deployment
|
|
250
250
|
policies, because the protocol has no opinion about your house.
|
|
251
251
|
"""
|
|
252
|
-
|
|
252
|
+
from .paths import resolve_config
|
|
253
|
+
found = resolve_config("policies.json", "DOSYNC_POLICIES")
|
|
254
|
+
return str(found) if found else None
|
|
@@ -61,7 +61,8 @@ log = logging.getLogger("dosync.security")
|
|
|
61
61
|
|
|
62
62
|
# ── Configuración ─────────────────────────────────────────────────────────────
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
from .paths import certs_dir as _certs_dir
|
|
65
|
+
CERTS_DIR = _certs_dir()
|
|
65
66
|
CA_KEY_PATH = CERTS_DIR / "ca.key"
|
|
66
67
|
CA_CERT_PATH = CERTS_DIR / "ca.crt"
|
|
67
68
|
HUB_KEY_PATH = CERTS_DIR / "hub.key"
|
|
@@ -65,7 +65,12 @@ def _resolve_db_path() -> str:
|
|
|
65
65
|
"DOSYNC_DB_PATH is a deprecated alias for DOSYNC_DB — using %s. "
|
|
66
66
|
"Rename the variable to DOSYNC_DB.", alias)
|
|
67
67
|
return alias
|
|
68
|
-
|
|
68
|
+
# No explicit setting: resolve through the deployment layout, which keeps an
|
|
69
|
+
# existing database in the working directory rather than starting a new one
|
|
70
|
+
# beside it. A hub that came up with an empty chain after an upgrade would
|
|
71
|
+
# lose exactly the history this protocol exists to protect.
|
|
72
|
+
from dosync.paths import resolve_state
|
|
73
|
+
return str(resolve_state("dosync.db", "DOSYNC_DB", create=True))
|
|
69
74
|
|
|
70
75
|
|
|
71
76
|
# ── Estado global del hub ─────────────────────────────────────────────────────
|
|
@@ -732,6 +737,7 @@ async def lifespan(app: FastAPI):
|
|
|
732
737
|
try:
|
|
733
738
|
from dosync.adapters.declarative import DeclarativeAdapter
|
|
734
739
|
from dosync.declarative import load_directory
|
|
740
|
+
from dosync.paths import resolve_config_dir
|
|
735
741
|
|
|
736
742
|
_declared = load_directory()
|
|
737
743
|
if _declared:
|
|
@@ -751,7 +757,7 @@ async def lifespan(app: FastAPI):
|
|
|
751
757
|
hub.register_device(_manifest)
|
|
752
758
|
log.info("Declarative adapters: %d device(s) registered from %s",
|
|
753
759
|
len(_declared),
|
|
754
|
-
|
|
760
|
+
str(resolve_config_dir("declarative", "DOSYNC_DECLARATIVE_DIR")))
|
|
755
761
|
|
|
756
762
|
# A device whose file is gone must not keep answering intents. It is
|
|
757
763
|
# QUARANTINED rather than deleted: a directory that failed to mount looks
|
|
@@ -28,6 +28,7 @@ dosync/models.py
|
|
|
28
28
|
dosync/operation_guards.py
|
|
29
29
|
dosync/operation_supervisor.py
|
|
30
30
|
dosync/operations.py
|
|
31
|
+
dosync/paths.py
|
|
31
32
|
dosync/plugins.py
|
|
32
33
|
dosync/policies.py
|
|
33
34
|
dosync/policy_config.py
|
|
@@ -79,6 +80,7 @@ tests/test_db.py
|
|
|
79
80
|
tests/test_declarative_adapters.py
|
|
80
81
|
tests/test_declarative_quarantine.py
|
|
81
82
|
tests/test_deployment_env_contract.py
|
|
83
|
+
tests/test_deployment_layout.py
|
|
82
84
|
tests/test_device_health.py
|
|
83
85
|
tests/test_device_heartbeat.py
|
|
84
86
|
tests/test_direct_action_governance.py
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Where a deployment keeps its configuration and its state (2026-08-08).
|
|
2
|
+
|
|
3
|
+
Found while preparing to reflash the reference hub: its configuration lived in
|
|
4
|
+
nine places, four of which the author did not remember existed, and three of
|
|
5
|
+
those were inside a git clone. A `git clean -fdx` would have destroyed a
|
|
6
|
+
42,000-entry audit chain and the CA's private key.
|
|
7
|
+
|
|
8
|
+
Worth naming as the contradiction it was: this protocol argues that its evidence
|
|
9
|
+
survives someone with root access, and it did not survive someone tidying a
|
|
10
|
+
repository. The sophisticated threat was covered and the trivial one was not.
|
|
11
|
+
|
|
12
|
+
The rules below are the panel's, and each exists because getting it wrong has a
|
|
13
|
+
specific cost.
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from dosync import paths
|
|
21
|
+
|
|
22
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def clean_env(tmp_path, monkeypatch):
|
|
27
|
+
"""A deployment rooted entirely under tmp_path, in user mode."""
|
|
28
|
+
monkeypatch.setenv("DOSYNC_INSTALL_MODE", "user")
|
|
29
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
|
30
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
31
|
+
for v in ("DOSYNC_DB", "DOSYNC_CERTS_DIR", "DOSYNC_POLICIES",
|
|
32
|
+
"DOSYNC_CHECKPOINT_DIR", "DOSYNC_ARCHIVE_DIR",
|
|
33
|
+
"DOSYNC_DECLARATIVE_DIR"):
|
|
34
|
+
monkeypatch.delenv(v, raising=False)
|
|
35
|
+
work = tmp_path / "work"
|
|
36
|
+
work.mkdir()
|
|
37
|
+
monkeypatch.chdir(work)
|
|
38
|
+
return tmp_path, work
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── The two modes ───────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def test_user_and_system_modes_resolve_differently(monkeypatch):
|
|
44
|
+
"""`pipx` installs as an ordinary user who cannot write to /etc or
|
|
45
|
+
/var/lib; a systemd unit runs as root and should use them. Picking one
|
|
46
|
+
layout would have broken the other."""
|
|
47
|
+
monkeypatch.setenv("DOSYNC_INSTALL_MODE", "system")
|
|
48
|
+
assert paths.state_dir() == Path("/var/lib/dosync")
|
|
49
|
+
assert Path("/etc/dosync") in paths.config_dirs()
|
|
50
|
+
|
|
51
|
+
monkeypatch.setenv("DOSYNC_INSTALL_MODE", "user")
|
|
52
|
+
monkeypatch.setenv("XDG_STATE_HOME", "/tmp/xdg-state")
|
|
53
|
+
assert paths.state_dir() == Path("/tmp/xdg-state/dosync")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_config_cascades_from_user_to_system(clean_env, monkeypatch):
|
|
57
|
+
"""User location first, system second — so a per-user install finds its own
|
|
58
|
+
file, and falls back to a system-wide default when there is none."""
|
|
59
|
+
tmp, _ = clean_env
|
|
60
|
+
dirs = paths.config_dirs()
|
|
61
|
+
assert dirs[0] == tmp / "cfg" / "dosync"
|
|
62
|
+
assert dirs[-1] == Path("/etc/dosync")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Compatibility: the rule that protects the existing deployment ───────────
|
|
66
|
+
|
|
67
|
+
def test_an_explicit_variable_always_wins(clean_env, monkeypatch):
|
|
68
|
+
"""An operator who said where wins over any inference. Every existing
|
|
69
|
+
deployment is configured this way and must be unaffected."""
|
|
70
|
+
monkeypatch.setenv("DOSYNC_DB", "/tmp/chosen.db")
|
|
71
|
+
assert paths.resolve_state("dosync.db", "DOSYNC_DB") == Path("/tmp/chosen.db")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_an_existing_database_in_the_working_directory_keeps_being_used(clean_env):
|
|
75
|
+
"""The rule that matters most. A running deployment with 42,000 entries must
|
|
76
|
+
not come up with an empty chain because the layout changed — that would lose
|
|
77
|
+
exactly the history this protocol exists to protect."""
|
|
78
|
+
_, work = clean_env
|
|
79
|
+
(work / "dosync.db").write_text("existing deployment")
|
|
80
|
+
|
|
81
|
+
assert paths.resolve_state("dosync.db", "DOSYNC_DB") == work / "dosync.db"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_the_legacy_path_is_used_with_a_warning_not_silently(clean_env, caplog):
|
|
85
|
+
"""Using it quietly would leave the operator unaware their data sits inside
|
|
86
|
+
a source tree."""
|
|
87
|
+
import logging
|
|
88
|
+
|
|
89
|
+
_, work = clean_env
|
|
90
|
+
(work / "dosync.db").write_text("x")
|
|
91
|
+
with caplog.at_level(logging.WARNING):
|
|
92
|
+
paths.resolve_state("dosync.db", "DOSYNC_DB")
|
|
93
|
+
assert any("git clean" in str(r.msg) or "working directory" in str(r.msg)
|
|
94
|
+
for r in caplog.records)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_data_in_two_places_is_an_error_not_a_choice(clean_env):
|
|
98
|
+
"""Choosing wrong means writing to one chain and auditing the other, and
|
|
99
|
+
only the operator knows which is current."""
|
|
100
|
+
tmp, work = clean_env
|
|
101
|
+
(work / "dosync.db").write_text("old")
|
|
102
|
+
modern = paths.state_dir()
|
|
103
|
+
modern.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
(modern / "dosync.db").write_text("new")
|
|
105
|
+
|
|
106
|
+
with pytest.raises(RuntimeError) as e:
|
|
107
|
+
paths.resolve_state("dosync.db", "DOSYNC_DB")
|
|
108
|
+
assert "will not guess" in str(e.value)
|
|
109
|
+
assert str(work) in str(e.value) and str(modern) in str(e.value), \
|
|
110
|
+
"the error must name both paths — the operator has to go look at them"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_clean_install_uses_the_state_directory(clean_env):
|
|
114
|
+
tmp, _ = clean_env
|
|
115
|
+
p = paths.resolve_state("dosync.db", "DOSYNC_DB")
|
|
116
|
+
assert str(p).startswith(str(paths.state_dir()))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── PKI ─────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def test_the_pki_directory_is_created_private(clean_env):
|
|
122
|
+
"""A private key in a directory with whatever permissions it inherited is
|
|
123
|
+
the kind of detail nobody notices until an audit asks."""
|
|
124
|
+
d = paths.certs_dir()
|
|
125
|
+
assert d.exists()
|
|
126
|
+
assert oct(d.stat().st_mode)[-3:] == "700", \
|
|
127
|
+
f"certs directory is {oct(d.stat().st_mode)[-3:]}, expected 700"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_existing_certificates_are_not_abandoned(clean_env):
|
|
131
|
+
"""Regenerating a CA orphans every device certificate issued from it, and
|
|
132
|
+
invalidates the certificate an operator installed on their laptop."""
|
|
133
|
+
_, work = clean_env
|
|
134
|
+
legacy = work / "certs"
|
|
135
|
+
legacy.mkdir()
|
|
136
|
+
(legacy / "ca.crt").write_text("existing CA")
|
|
137
|
+
|
|
138
|
+
assert paths.certs_dir() == legacy
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Visibility ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def test_the_hub_can_report_which_paths_it_resolved(clean_env):
|
|
144
|
+
"""An operator editing /etc/dosync/policies.json while the hub reads
|
|
145
|
+
~/.config/dosync/policies.json believes they are protected by a policy the
|
|
146
|
+
hub never loaded. Silent divergence between what is edited and what runs is
|
|
147
|
+
the failure this project keeps finding."""
|
|
148
|
+
d = paths.describe()
|
|
149
|
+
assert set(d) == {"mode", "config_dirs", "state_dir", "certs_dir"}
|
|
150
|
+
assert d["mode"] in ("user", "system")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_configuration_is_found_in_the_config_directory(clean_env):
|
|
154
|
+
tmp, _ = clean_env
|
|
155
|
+
cfg = tmp / "cfg" / "dosync"
|
|
156
|
+
cfg.mkdir(parents=True)
|
|
157
|
+
(cfg / "policies.json").write_text("{}")
|
|
158
|
+
|
|
159
|
+
assert paths.resolve_config("policies.json", "DOSYNC_POLICIES") == \
|
|
160
|
+
cfg / "policies.json"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_absent_configuration_is_none_not_a_missing_path(clean_env):
|
|
164
|
+
"""A hub with no deployment policies is conforming — absence is a normal
|
|
165
|
+
state and callers should not have to tell it from a path that happens not to
|
|
166
|
+
exist."""
|
|
167
|
+
assert paths.resolve_config("policies.json", "DOSYNC_POLICIES") is None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Found by installing from PyPI on a clean machine (2026-08-08) ───────────
|
|
171
|
+
|
|
172
|
+
def test_the_hub_says_when_it_is_only_reachable_locally():
|
|
173
|
+
"""The commonest deployment is a headless Raspberry Pi whose operator is on
|
|
174
|
+
SSH and wants the dashboard from their laptop. Binding to loopback is the
|
|
175
|
+
right default — a hub reachable from the whole network because nobody chose
|
|
176
|
+
that is worse than one needing a flag — but `Uvicorn running on
|
|
177
|
+
http://127.0.0.1` does not tell them why their browser cannot connect.
|
|
178
|
+
|
|
179
|
+
The default does not change. The silence does.
|
|
180
|
+
"""
|
|
181
|
+
import inspect
|
|
182
|
+
|
|
183
|
+
from dosync import cli
|
|
184
|
+
|
|
185
|
+
src = inspect.getsource(cli)
|
|
186
|
+
assert "loopback only" in src
|
|
187
|
+
assert "--host 0.0.0.0" in src, "and must say exactly how to change it"
|
|
188
|
+
assert 'default=os.environ.get("DOSYNC_HOST", "127.0.0.1")' in src, \
|
|
189
|
+
"the safe default must stay"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_the_published_version_and_the_source_version_cannot_silently_diverge():
|
|
193
|
+
"""`paths.py` was added under 0.4.2 while 0.4.2 was already on PyPI — two
|
|
194
|
+
different artefacts with one number. Anyone reporting a bug "in 0.4.2" would
|
|
195
|
+
leave us unable to tell which one they have.
|
|
196
|
+
|
|
197
|
+
This pins the specific mistake: the version must move when behaviour does.
|
|
198
|
+
"""
|
|
199
|
+
import re
|
|
200
|
+
|
|
201
|
+
import dosync
|
|
202
|
+
|
|
203
|
+
changelog = (REPO / "CHANGELOG.md").read_text()
|
|
204
|
+
versions = re.findall(r"^## \[([0-9]+\.[0-9]+\.[0-9]+)\]", changelog, re.M)
|
|
205
|
+
|
|
206
|
+
assert dosync.__version__ in versions or dosync.__version__ > max(versions), (
|
|
207
|
+
f"version {dosync.__version__} is neither released nor ahead of the "
|
|
208
|
+
f"latest changelog entry {max(versions)} — a behaviour change under an "
|
|
209
|
+
f"already-published number produces two artefacts with one name")
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|