Files
DECNET/decnet/topology/hashing.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

67 lines
2.0 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""Canonical hash of a hydrated topology dict.
Both master and agent need to agree on "is the applied state the one
the master intends?". We answer that by hashing the hydrated topology
blob on both sides and comparing the hex digests. The function has to
be **pure** and **deterministic**: same logical state → same hash, no
matter the dict-key order, no matter the timezone of a ``created_at``.
Normalisation rules (applied to a deep copy — input is never mutated):
- Drop fields that change on every read but don't change behaviour:
``created_at``, ``status_changed_at``, ``updated_at``, ``last_seen``,
``status``, ``version``, ``last_error``.
- Drop purely-cosmetic canvas positions (``x``, ``y``, ``w``, ``h``)
everywhere — they're client-side layout, not deployment state.
- Leave everything else alone; sort-keys=True + ``separators``
collapse whitespace and fix ordering.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
# Fields that vary over time or come from layout and must NOT feed the
# applied-state hash. Dropped at every nesting level.
_VOLATILE_KEYS = frozenset(
{
"created_at",
"status_changed_at",
"updated_at",
"last_seen",
"status",
"version",
"last_error",
"x",
"y",
"w",
"h",
}
)
def _strip(value: Any) -> Any:
"""Return a deep copy of *value* with volatile keys removed."""
if isinstance(value, dict):
return {k: _strip(v) for k, v in value.items() if k not in _VOLATILE_KEYS}
if isinstance(value, list):
return [_strip(v) for v in value]
return value
def canonical_hash(hydrated: dict) -> str:
"""Return the SHA-256 hex digest of *hydrated*'s canonical form."""
normalised = _strip(hydrated)
blob = json.dumps(
normalised,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
__all__ = ["canonical_hash"]