clm-kernel 0.0.0__py3-none-any.whl
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.
- clm/__init__.py +230 -0
- clm/__main__.py +25 -0
- clm/cross_cutting/__init__.py +50 -0
- clm/cross_cutting/_test_runner.py +36 -0
- clm/cross_cutting/async_utils.py +25 -0
- clm/cross_cutting/cel_builder.py +123 -0
- clm/cross_cutting/cel_converter.py +697 -0
- clm/cross_cutting/cel_engine.py +252 -0
- clm/cross_cutting/cel_ops.py +72 -0
- clm/cross_cutting/cel_ops_db.py +157 -0
- clm/cross_cutting/cel_ops_files.py +99 -0
- clm/cross_cutting/cel_ops_state.py +71 -0
- clm/cross_cutting/cel_ops_strings.py +64 -0
- clm/cross_cutting/cel_resolver.py +63 -0
- clm/cross_cutting/cel_slices.py +56 -0
- clm/cross_cutting/cel_store.py +330 -0
- clm/cross_cutting/clm_logger.py +140 -0
- clm/cross_cutting/config/__init__.py +155 -0
- clm/cross_cutting/config/config_constants.py +392 -0
- clm/cross_cutting/config/env_parameters.py +90 -0
- clm/cross_cutting/config/logging.py +378 -0
- clm/cross_cutting/config/settings.py +504 -0
- clm/cross_cutting/context.py +5 -0
- clm/cross_cutting/domain_models.py +55 -0
- clm/cross_cutting/domain_types.py +61 -0
- clm/cross_cutting/effects.py +67 -0
- clm/cross_cutting/errors.py +153 -0
- clm/cross_cutting/governance.py +331 -0
- clm/cross_cutting/guards.py +41 -0
- clm/cross_cutting/mcard.py +321 -0
- clm/cross_cutting/native.py +185 -0
- clm/cross_cutting/observability.py +289 -0
- clm/cross_cutting/pcard.py +434 -0
- clm/cross_cutting/registry.py +71 -0
- clm/cross_cutting/resources.py +84 -0
- clm/cross_cutting/secrets.py +44 -0
- clm/cross_cutting/subprocess_utils.py +118 -0
- clm/cross_cutting/telemetry.py +164 -0
- clm/cross_cutting/testing/__init__.py +8 -0
- clm/cross_cutting/testing/comparator.py +187 -0
- clm/cross_cutting/testing/datasets.py +87 -0
- clm/cross_cutting/timeutil.py +20 -0
- clm/cross_cutting/types/new_type_check.py +0 -0
- clm/cross_cutting/utils/__init__.py +1 -0
- clm/cross_cutting/utils/url_safety.py +50 -0
- clm/cross_cutting/vcard.py +560 -0
- clm/cross_cutting/writer.py +118 -0
- clm/layer0/README.md +50 -0
- clm/layer0/__init__.py +223 -0
- clm/layer0/algebra.py +60 -0
- clm/layer0/algebra_baldwin.py +133 -0
- clm/layer0/algebra_baldwin_generators.py +36 -0
- clm/layer0/algebra_equivalence.py +262 -0
- clm/layer0/algebra_ingest.py +306 -0
- clm/layer0/algebra_manifest_io.py +156 -0
- clm/layer0/assembler.py +481 -0
- clm/layer0/cel/__init__.py +23 -0
- clm/layer0/cel_eval.py +491 -0
- clm/layer0/codec.py +106 -0
- clm/layer0/context.py +122 -0
- clm/layer0/db.py +160 -0
- clm/layer0/exceptions.py +73 -0
- clm/layer0/fibration.py +106 -0
- clm/layer0/gates.py +50 -0
- clm/layer0/hash.py +80 -0
- clm/layer0/loader.py +826 -0
- clm/layer0/math_utils.py +163 -0
- clm/layer0/mcard.py +96 -0
- clm/layer0/model/__init__.py +0 -0
- clm/layer0/model/action.py +223 -0
- clm/layer0/model/card.py +280 -0
- clm/layer0/model/card_triad.py +427 -0
- clm/layer0/model/compatibility.py +60 -0
- clm/layer0/model/dictionary.py +46 -0
- clm/layer0/model/dots.py +331 -0
- clm/layer0/model/event_producer.py +88 -0
- clm/layer0/model/g_time.py +89 -0
- clm/layer0/model/handle.py +163 -0
- clm/layer0/model/hash/__init__.py +6 -0
- clm/layer0/model/hash/algorithms/__init__.py +3 -0
- clm/layer0/model/hash/algorithms/custom_hash.py +7 -0
- clm/layer0/model/hash/algorithms/local_sha256.py +178 -0
- clm/layer0/model/hash/constants.py +53 -0
- clm/layer0/model/hash/enums.py +60 -0
- clm/layer0/model/hash/validator.py +307 -0
- clm/layer0/model/interpreter.py +275 -0
- clm/layer0/model/pagination.py +45 -0
- clm/layer0/model/pcard.py +716 -0
- clm/layer0/model/schema.py +44 -0
- clm/layer0/model/utils/__init__.py +1 -0
- clm/layer0/model/utils/content_analyzer.py +152 -0
- clm/layer0/model/validators/__init__.py +1 -0
- clm/layer0/model/validators/base_validator.py +44 -0
- clm/layer0/model/validators/binary_validator.py +66 -0
- clm/layer0/model/validators/text_validator.py +82 -0
- clm/layer0/model/validators/validation_registry.py +49 -0
- clm/layer0/model/vcard.py +823 -0
- clm/layer0/model/vcard_ext/__init__.py +39 -0
- clm/layer0/model/vcard_ext/core.py +60 -0
- clm/layer0/model/vcard_ext/network.py +23 -0
- clm/layer0/model/vcard_ext/observability.py +63 -0
- clm/layer0/model/vcard_ext/storage.py +60 -0
- clm/layer0/model/vcard_ext/vendors.py +211 -0
- clm/layer0/model/vcard_sandwich.py +119 -0
- clm/layer0/model/vcard_vocabulary.py +463 -0
- clm/layer0/model/workflow.py +274 -0
- clm/layer0/narrative.py +431 -0
- clm/layer0/ontology/collection.py +125 -0
- clm/layer0/parser.py +429 -0
- clm/layer0/relations.py +73 -0
- clm/layer0/rule_evaluator.py +251 -0
- clm/layer0/schema/__init__.py +38 -0
- clm/layer0/schema/ddl.py +97 -0
- clm/layer0/templates/abstract.yaml +99 -0
- clm/layer0/templates/balanced.yaml +253 -0
- clm/layer0/templates/concrete.yaml +172 -0
- clm/layer0/type_registry.py +223 -0
- clm/layer0/types.py +204 -0
- clm/layer0/utils.py +886 -0
- clm/layer0/verifier_core.py +262 -0
- clm/layer1/__init__.py +31 -0
- clm/layer1/action_dispatcher.py +275 -0
- clm/layer1/adapter_registry.py +233 -0
- clm/layer1/algebra_baldwin_generators.py +192 -0
- clm/layer1/arrow_compiler.py +525 -0
- clm/layer1/batch_processor.py +152 -0
- clm/layer1/cel/__init__.py +27 -0
- clm/layer1/cel/builder.py +173 -0
- clm/layer1/engine.py +759 -0
- clm/layer1/engine_async.py +89 -0
- clm/layer1/eval_callback.py +49 -0
- clm/layer1/gate_composer.py +229 -0
- clm/layer1/hoare_sandwich.py +298 -0
- clm/layer1/identity_guard.py +348 -0
- clm/layer1/matrix.py +138 -0
- clm/layer1/neural_runtime.py +221 -0
- clm/layer1/operations/__init__.py +123 -0
- clm/layer1/operations/augment.py +156 -0
- clm/layer1/operations/base.py +109 -0
- clm/layer1/operations/builtins.py +338 -0
- clm/layer1/operations/dispatch.py +144 -0
- clm/layer1/operations/exclude.py +107 -0
- clm/layer1/operations/handle.py +197 -0
- clm/layer1/operations/identity.py +139 -0
- clm/layer1/operations/invert.py +66 -0
- clm/layer1/operations/loader.py +116 -0
- clm/layer1/operations/manipulators/__init__.py +23 -0
- clm/layer1/operations/manipulators/document.py +211 -0
- clm/layer1/operations/manipulators/media.py +134 -0
- clm/layer1/operations/manipulators/structured.py +159 -0
- clm/layer1/operations/manipulators/tabular.py +334 -0
- clm/layer1/operations/port.py +65 -0
- clm/layer1/operations/services.py +529 -0
- clm/layer1/operations/split.py +117 -0
- clm/layer1/operations/substitute.py +75 -0
- clm/layer1/petri.py +1052 -0
- clm/layer1/pocketflow_orchestrator.py +408 -0
- clm/layer1/pocketflow_scheduler.py +519 -0
- clm/layer1/protocols.py +197 -0
- clm/layer1/runtime_adapter.py +718 -0
- clm/layer1/runtimes/__init__.py +71 -0
- clm/layer1/runtimes/_generated_assertions.py +125 -0
- clm/layer1/runtimes/_generated_types.py +42 -0
- clm/layer1/runtimes/base.py +265 -0
- clm/layer1/runtimes/binary.py +101 -0
- clm/layer1/runtimes/factory.py +203 -0
- clm/layer1/runtimes/javascript.py +321 -0
- clm/layer1/runtimes/javascript_runtime.js +33 -0
- clm/layer1/runtimes/js_scripts/isomorphism.mjs +22 -0
- clm/layer1/runtimes/js_scripts/loader.mjs +52 -0
- clm/layer1/runtimes/lambda_calc.py +126 -0
- clm/layer1/runtimes/python.py +288 -0
- clm/layer1/runtimes/python_runtime.py +41 -0
- clm/layer1/runtimes/script.py +114 -0
- clm/layer1/sandbox.py +334 -0
- clm/layer1/savepoint_guard.py +136 -0
- clm/layer1/sparse_net.py +122 -0
- clm/layer1/spec_tester.py +165 -0
- clm/layer1/store.py +295 -0
- clm/layer1/test_runner.py +498 -0
- clm/layer1/type_inspector.py +166 -0
- clm/layer1/verifier.py +772 -0
- clm/layer1/vm.py +91 -0
- clm/layer2/__init__.py +78 -0
- clm/layer2/card_collection.py +678 -0
- clm/layer2/classifier.py +479 -0
- clm/layer2/engine/__init__.py +1 -0
- clm/layer2/engine/abstract_sql_engine.py +320 -0
- clm/layer2/engine/base.py +193 -0
- clm/layer2/engine/duckdb_engine.py +576 -0
- clm/layer2/engine/sqlite_engine.py +518 -0
- clm/layer2/fiber/__init__.py +31 -0
- clm/layer2/fiber/context.py +89 -0
- clm/layer2/fiber/disposable.py +163 -0
- clm/layer2/fiber/fiber.py +125 -0
- clm/layer2/fiber/hoare.py +191 -0
- clm/layer2/file_io.py +376 -0
- clm/layer2/file_registrar.py +77 -0
- clm/layer2/ingest.py +202 -0
- clm/layer2/lineage.py +322 -0
- clm/layer2/mcard_fs.py +262 -0
- clm/layer2/merkle.py +11 -0
- clm/layer2/mime.py +244 -0
- clm/layer2/mime_detector.py +245 -0
- clm/layer2/private_collection.py +118 -0
- clm/layer2/shared_store.py +11 -0
- clm/layer2/storage/__init__.py +190 -0
- clm/layer2/storage/db_bitemporal.py +141 -0
- clm/layer2/storage/db_driver.py +331 -0
- clm/layer2/storage/db_hyperlinks.py +214 -0
- clm/layer2/storage/db_pool.py +139 -0
- clm/layer2/storage/db_repository.py +229 -0
- clm/layer2/storage/savepoint.py +78 -0
- clm/layer2/storage/sqlite_wal.py +74 -0
- clm/layer2/tridb.py +245 -0
- clm/layer2/vcard_receipt.py +71 -0
- clm/layer2/vfs.py +17 -0
- clm/layer3/__init__.py +141 -0
- clm/layer3/agency/__init__.py +19 -0
- clm/layer3/agency/config.py +220 -0
- clm/layer3/agency/providers/__init__.py +15 -0
- clm/layer3/agency/providers/base.py +91 -0
- clm/layer3/agency/providers/mlc_llm.py +169 -0
- clm/layer3/agency/providers/ollama.py +203 -0
- clm/layer3/agency/router.py +71 -0
- clm/layer3/agency/runtime.py +524 -0
- clm/layer3/gateway.py +353 -0
- clm/layer3/open_interpreter_adapter.py +159 -0
- clm/layer3/rag/__init__.py +70 -0
- clm/layer3/rag/cli.py +228 -0
- clm/layer3/rag/config.py +153 -0
- clm/layer3/rag/embeddings/__init__.py +16 -0
- clm/layer3/rag/embeddings/base.py +79 -0
- clm/layer3/rag/embeddings/ollama.py +211 -0
- clm/layer3/rag/embeddings/vision.py +290 -0
- clm/layer3/rag/engine.py +276 -0
- clm/layer3/rag/graph/__init__.py +22 -0
- clm/layer3/rag/graph/community.py +198 -0
- clm/layer3/rag/graph/engine.py +433 -0
- clm/layer3/rag/graph/extractor.py +255 -0
- clm/layer3/rag/graph/schema.py +115 -0
- clm/layer3/rag/graph/store.py +641 -0
- clm/layer3/rag/indexer.py +333 -0
- clm/layer3/rag/llm_providers.json +52 -0
- clm/layer3/rag/semantic_versioning.py +313 -0
- clm/layer3/rag/vector/__init__.py +28 -0
- clm/layer3/rag/vector/handle_vector_store.py +749 -0
- clm/layer3/rag/vector/schema.py +151 -0
- clm/layer3/rag/vector/store.py +632 -0
- clm/layer3/reticulum/__init__.py +55 -0
- clm/layer3/reticulum/bridge.py +74 -0
- clm/layer3/reticulum/daemon.py +88 -0
- clm/layer3/reticulum/discovery.py +164 -0
- clm/layer3/reticulum/identity.py +107 -0
- clm/layer3/reticulum/media.py +148 -0
- clm/layer3/reticulum/transport.py +133 -0
- clm/layer3/satori/__init__.py +209 -0
- clm/layer3/satori/alpha_conversion.py +168 -0
- clm/layer3/satori/beta_reduction.py +359 -0
- clm/layer3/satori/continuation.py +38 -0
- clm/layer3/satori/eta_conversion.py +205 -0
- clm/layer3/satori/free_variables.py +163 -0
- clm/layer3/satori/io_effects.py +370 -0
- clm/layer3/satori/lambda_runtime.py +726 -0
- clm/layer3/satori/lambda_term.py +230 -0
- clm/layer3/satori/sheaf_audit.py +261 -0
- clm/layer3/satori/speech_act.py +279 -0
- clm/layer4/README.md +9 -0
- clm/layer4/__init__.py +67 -0
- clm/layer4/_sovereign_executor.py +580 -0
- clm/layer4/action_commands.py +719 -0
- clm/layer4/api.py +236 -0
- clm/layer4/bootstrap/__init__.py +13 -0
- clm/layer4/bootstrap/bootstrapper.py +118 -0
- clm/layer4/bootstrap/cleanup.py +44 -0
- clm/layer4/bootstrap/clm_hooks.py +49 -0
- clm/layer4/bootstrap/genesis.py +77 -0
- clm/layer4/bootstrap/injection.py +125 -0
- clm/layer4/cli.py +549 -0
- clm/layer4/collection_manager.py +553 -0
- clm/layer4/commands/__init__.py +81 -0
- clm/layer4/commands/action_cmd.py +42 -0
- clm/layer4/commands/check_cmd.py +82 -0
- clm/layer4/commands/envelope.py +73 -0
- clm/layer4/commands/evaluate_cmd.py +224 -0
- clm/layer4/commands/mcard_cmd.py +24 -0
- clm/layer4/commands/petri_cmd.py +92 -0
- clm/layer4/commands/tridb_cmd.py +319 -0
- clm/layer4/commands/version_cmd.py +27 -0
- clm/layer4/diff_commands.py +167 -0
- clm/layer4/eoa_sponsor.py +232 -0
- clm/layer4/events.py +69 -0
- clm/layer4/flux_gateway.py +133 -0
- clm/layer4/gateway.py +91 -0
- clm/layer4/loader.py +307 -0
- clm/layer4/mcard_commands.py +166 -0
- clm/layer4/membrane.py +273 -0
- clm/layer4/navigation.py +50 -0
- clm/layer4/protocol/protocol_entry_python.py +108 -0
- clm/layer4/protocol/python_sync.py +126 -0
- clm/layer4/runner.py +150 -0
- clm/layer4/satori.py +262 -0
- clm/layer4/server/python_sync_client.py +150 -0
- clm/layer4/server/python_sync_server.py +139 -0
- clm/layer4/server/service_api.py +315 -0
- clm/layer4/server/start_servers.py +107 -0
- clm/layer4/server/stop_servers.py +24 -0
- clm/layer4/simulated_optimization_fix.py +0 -0
- clm/layer4/storage.py +463 -0
- clm/layer4/tridb_admin.py +268 -0
- clm/layer4/websocket/test_ws_client.py +104 -0
- clm/layer4/websocket/test_ws_server.py +138 -0
- clm/layer4/websocket/ws_server_local.py +160 -0
- clm/layer5/__init__.py +51 -0
- clm/layer5/async_batch_writer.py +133 -0
- clm/layer5/learning.py +276 -0
- clm/layer5/telemetry_hook.py +107 -0
- clm/layer5/type_lattice.py +238 -0
- clm/py.typed +0 -0
- clm/schemas/__init__.py +652 -0
- clm/schemas/abstract_face_schema.yaml +77 -0
- clm/schemas/cel_schema.yaml +416 -0
- clm/schemas/lifecycle/__init__.py +45 -0
- clm/schemas/lifecycle/models.py +123 -0
- clm/schemas/lifecycle/service.py +364 -0
- clm/schemas/mcard_schema.sql +192 -0
- clm/schemas/mcard_vector_schema.sql +283 -0
- clm/schemas/mlp-context-v1.json +91 -0
- clm_kernel-0.0.0.dist-info/METADATA +121 -0
- clm_kernel-0.0.0.dist-info/RECORD +333 -0
- clm_kernel-0.0.0.dist-info/WHEEL +4 -0
- clm_kernel-0.0.0.dist-info/entry_points.txt +3 -0
- clm_kernel-0.0.0.dist-info/licenses/LICENSE +21 -0
clm/layer0/utils.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"""Shared utility functions and helpers for the CLM Python implementation.
|
|
2
|
+
|
|
3
|
+
This module consolidates common routines for path resolution, hashing,
|
|
4
|
+
environment checks, and system capabilities to avoid code duplication
|
|
5
|
+
and improve maintainability.
|
|
6
|
+
|
|
7
|
+
Hash function upgradeability (INV-02):
|
|
8
|
+
All content hashing routes through ``clm_hash_string`` / ``clm_hash_file``.
|
|
9
|
+
SHA-256 is the current default, but the system is designed to be
|
|
10
|
+
algorithm-agnostic. Stored hash values carry an algorithm prefix
|
|
11
|
+
(e.g. ``sha256:<hex>``) so multiple algorithms can coexist.
|
|
12
|
+
To switch algorithms, change ``HASH_PREFIX`` and the implementation
|
|
13
|
+
of ``clm_hash_string`` — all call sites will pick up the change.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import enum
|
|
18
|
+
import hashlib
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from clm.cross_cutting.clm_logger import get_logger
|
|
26
|
+
|
|
27
|
+
_logger = get_logger("clm.utils")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_line_endings_bytes(content: bytes) -> bytes:
|
|
31
|
+
"""Normalize CRLF or CR line endings to LF in bytes."""
|
|
32
|
+
return content.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
33
|
+
|
|
34
|
+
def normalize_line_endings_str(content: str) -> str:
|
|
35
|
+
"""Normalize CRLF or CR line endings to LF in string."""
|
|
36
|
+
return content.replace("\r\n", "\n").replace("\r", "\n")
|
|
37
|
+
|
|
38
|
+
# ── Hash function infrastructure (INV-02: upgradable hash function) ────────
|
|
39
|
+
|
|
40
|
+
BLAKE3_HASH_PREFIX: str = "blake3:"
|
|
41
|
+
SHA256_HASH_PREFIX: str = "sha256:"
|
|
42
|
+
|
|
43
|
+
#: Default algorithm prefix for stored hash values.
|
|
44
|
+
DEFAULT_HASH_PREFIX: str = BLAKE3_HASH_PREFIX
|
|
45
|
+
HASH_PREFIX: str = DEFAULT_HASH_PREFIX
|
|
46
|
+
|
|
47
|
+
#: Default collision threshold before triggering an algorithm upgrade.
|
|
48
|
+
DEFAULT_COLLISION_THRESHOLD: int = 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── HashProvider protocol (Step 2: provider registry) ──────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class HashProvider(Protocol):
|
|
56
|
+
"""Protocol for pluggable content-hash algorithms."""
|
|
57
|
+
|
|
58
|
+
def hash(self, content: bytes) -> str:
|
|
59
|
+
"""Compute the raw hex digest of content."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def prefix(self) -> str:
|
|
63
|
+
"""Algorithm prefix (e.g. 'sha256:')."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Zero-dependency pure Python BLAKE3 engine (INV-02 fallback) ─────────
|
|
68
|
+
_B3_IV = (
|
|
69
|
+
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
|
|
70
|
+
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
|
|
71
|
+
)
|
|
72
|
+
_B3_MSG_PERM = (2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8)
|
|
73
|
+
_B3_CHUNK_START, _B3_CHUNK_END, _B3_PARENT, _B3_ROOT = 1, 2, 4, 8
|
|
74
|
+
|
|
75
|
+
def _b3_rotr(w: int, r: int) -> int:
|
|
76
|
+
return ((w >> r) | (w << (32 - r))) & 0xFFFFFFFF
|
|
77
|
+
|
|
78
|
+
def _b3_g(s: list, a: int, b: int, c: int, d: int, mx: int, my: int) -> None:
|
|
79
|
+
s[a] = (s[a] + s[b] + mx) & 0xFFFFFFFF
|
|
80
|
+
s[d] = _b3_rotr(s[d] ^ s[a], 16)
|
|
81
|
+
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
|
|
82
|
+
s[b] = _b3_rotr(s[b] ^ s[c], 12)
|
|
83
|
+
s[a] = (s[a] + s[b] + my) & 0xFFFFFFFF
|
|
84
|
+
s[d] = _b3_rotr(s[d] ^ s[a], 8)
|
|
85
|
+
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
|
|
86
|
+
s[b] = _b3_rotr(s[b] ^ s[c], 7)
|
|
87
|
+
|
|
88
|
+
def _b3_round(s: list, m: list) -> None:
|
|
89
|
+
_b3_g(s, 0, 4, 8, 12, m[0], m[1])
|
|
90
|
+
_b3_g(s, 1, 5, 9, 13, m[2], m[3])
|
|
91
|
+
_b3_g(s, 2, 6, 10, 14, m[4], m[5])
|
|
92
|
+
_b3_g(s, 3, 7, 11, 15, m[6], m[7])
|
|
93
|
+
_b3_g(s, 0, 5, 10, 15, m[8], m[9])
|
|
94
|
+
_b3_g(s, 1, 6, 11, 12, m[10], m[11])
|
|
95
|
+
_b3_g(s, 2, 7, 8, 13, m[12], m[13])
|
|
96
|
+
_b3_g(s, 3, 4, 9, 14, m[14], m[15])
|
|
97
|
+
|
|
98
|
+
def _b3_compress(cv: list, block_words: list, block_len: int, counter: int, flags: int) -> list:
|
|
99
|
+
s = list(cv) + list(_B3_IV[:4]) + [counter & 0xFFFFFFFF, (counter >> 32) & 0xFFFFFFFF, block_len, flags]
|
|
100
|
+
m = list(block_words)
|
|
101
|
+
for r in range(7):
|
|
102
|
+
_b3_round(s, m)
|
|
103
|
+
if r < 6:
|
|
104
|
+
m = [m[_B3_MSG_PERM[i]] for i in range(16)]
|
|
105
|
+
out = [0] * 16
|
|
106
|
+
for i in range(8):
|
|
107
|
+
out[i] = (s[i] ^ s[i + 8]) & 0xFFFFFFFF
|
|
108
|
+
out[i + 8] = (s[i + 8] ^ cv[i]) & 0xFFFFFFFF
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
def _b3_words_from_bytes(b: bytearray) -> list:
|
|
112
|
+
words = [0] * 16
|
|
113
|
+
for i in range(min(16, (len(b) + 3) // 4)):
|
|
114
|
+
chunk = b[i*4 : (i+1)*4]
|
|
115
|
+
words[i] = int.from_bytes(chunk, "little")
|
|
116
|
+
return words
|
|
117
|
+
|
|
118
|
+
def _b3_words_to_hex(words: list) -> str:
|
|
119
|
+
import struct
|
|
120
|
+
return "".join(struct.pack("<I", w).hex() for w in words)
|
|
121
|
+
|
|
122
|
+
class _B3ChunkState:
|
|
123
|
+
def __init__(self, counter: int, flags: int = 0):
|
|
124
|
+
self.cv = list(_B3_IV)
|
|
125
|
+
self.counter = counter
|
|
126
|
+
self.flags = flags
|
|
127
|
+
self.block = bytearray()
|
|
128
|
+
self.blocks_compressed = 0
|
|
129
|
+
|
|
130
|
+
def start_flag(self) -> int:
|
|
131
|
+
return _B3_CHUNK_START if self.blocks_compressed == 0 else 0
|
|
132
|
+
|
|
133
|
+
def update(self, data: bytes) -> None:
|
|
134
|
+
offset = 0
|
|
135
|
+
while offset < len(data):
|
|
136
|
+
if len(self.block) == 64:
|
|
137
|
+
words = _b3_words_from_bytes(self.block)
|
|
138
|
+
out = _b3_compress(self.cv, words, 64, self.counter, self.flags | self.start_flag())
|
|
139
|
+
self.cv = out[:8]
|
|
140
|
+
self.blocks_compressed += 1
|
|
141
|
+
self.block = bytearray()
|
|
142
|
+
take = min(64 - len(self.block), len(data) - offset)
|
|
143
|
+
self.block.extend(data[offset : offset + take])
|
|
144
|
+
offset += take
|
|
145
|
+
|
|
146
|
+
def output(self, is_root: bool = False) -> list:
|
|
147
|
+
words = _b3_words_from_bytes(self.block)
|
|
148
|
+
flags = self.flags | self.start_flag() | _B3_CHUNK_END
|
|
149
|
+
if is_root:
|
|
150
|
+
flags |= _B3_ROOT
|
|
151
|
+
return _b3_compress(self.cv, words, len(self.block), self.counter, flags)
|
|
152
|
+
|
|
153
|
+
def _b3_parent_output(left_cv: list, right_cv: list, is_root: bool = False) -> list:
|
|
154
|
+
block_words = left_cv[:8] + right_cv[:8]
|
|
155
|
+
flags = _B3_PARENT
|
|
156
|
+
if is_root:
|
|
157
|
+
flags |= _B3_ROOT
|
|
158
|
+
return _b3_compress(_B3_IV, block_words, 64, 0, flags)
|
|
159
|
+
|
|
160
|
+
def pure_python_blake3(data: bytes) -> str:
|
|
161
|
+
"""Zero-dependency pure Python BLAKE3 hash computation."""
|
|
162
|
+
if isinstance(data, str):
|
|
163
|
+
data = data.encode("utf-8")
|
|
164
|
+
if len(data) <= 1024:
|
|
165
|
+
c = _B3ChunkState(0, 0)
|
|
166
|
+
c.update(data)
|
|
167
|
+
return _b3_words_to_hex(c.output(True)[:8])
|
|
168
|
+
|
|
169
|
+
stack = []
|
|
170
|
+
chunk_counter = 0
|
|
171
|
+
offset = 0
|
|
172
|
+
while offset + 1024 < len(data):
|
|
173
|
+
chunk_bytes = data[offset : offset + 1024]
|
|
174
|
+
c = _B3ChunkState(chunk_counter, 0)
|
|
175
|
+
c.update(chunk_bytes)
|
|
176
|
+
chunk_cv = c.output(False)[:8]
|
|
177
|
+
chunk_counter += 1
|
|
178
|
+
offset += 1024
|
|
179
|
+
|
|
180
|
+
total = chunk_counter
|
|
181
|
+
while (total & 1) == 0:
|
|
182
|
+
left_cv = stack.pop()
|
|
183
|
+
chunk_cv = _b3_parent_output(left_cv, chunk_cv, False)[:8]
|
|
184
|
+
total >>= 1
|
|
185
|
+
stack.append(chunk_cv)
|
|
186
|
+
|
|
187
|
+
final = _B3ChunkState(chunk_counter, 0)
|
|
188
|
+
final.update(data[offset:])
|
|
189
|
+
out = final.output(len(stack) == 0)[:8]
|
|
190
|
+
if len(stack) == 0:
|
|
191
|
+
return _b3_words_to_hex(out)
|
|
192
|
+
|
|
193
|
+
while stack:
|
|
194
|
+
left = stack.pop()
|
|
195
|
+
is_root = (len(stack) == 0)
|
|
196
|
+
out = _b3_parent_output(left, out, is_root)[:8]
|
|
197
|
+
return _b3_words_to_hex(out)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Blake3Provider:
|
|
201
|
+
"""BLAKE3 hash provider (default active algorithm under INV-02)."""
|
|
202
|
+
|
|
203
|
+
def hash(self, content: bytes) -> str:
|
|
204
|
+
try:
|
|
205
|
+
import blake3 # type: ignore
|
|
206
|
+
return blake3.blake3(content).hexdigest()
|
|
207
|
+
except ImportError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Try native clm_rust_core binary
|
|
211
|
+
candidates = [
|
|
212
|
+
os.environ.get("CLM_RUST_CORE_BIN"),
|
|
213
|
+
os.path.join(os.path.dirname(__file__), "../../../clm_rust_core/target/release/clm_rust_core"),
|
|
214
|
+
os.path.join(os.path.dirname(__file__), "../../../clm_rust_core/target/debug/clm_rust_core"),
|
|
215
|
+
os.path.join(os.path.dirname(__file__), "../../../../kernel/clm_rust_core/target/debug/clm_rust_core"),
|
|
216
|
+
"clm_rust_core",
|
|
217
|
+
"b3sum",
|
|
218
|
+
]
|
|
219
|
+
if os.environ.get("CLM_ZERO_FS", "").lower() == "true":
|
|
220
|
+
return pure_python_blake3(content)
|
|
221
|
+
|
|
222
|
+
for bin_path in candidates:
|
|
223
|
+
if not bin_path:
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
import subprocess
|
|
227
|
+
cmd = [bin_path, "hash"] if "clm_rust_core" in bin_path else [bin_path, "--no-names"]
|
|
228
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
229
|
+
stdout, _ = proc.communicate(input=content)
|
|
230
|
+
if proc.returncode == 0 and stdout:
|
|
231
|
+
out = stdout.decode("utf-8").strip()
|
|
232
|
+
if out:
|
|
233
|
+
return out.split()[0]
|
|
234
|
+
except Exception:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# Zero-dependency pure Python fallback (works in hermetic sandboxes & temp mock roots)
|
|
238
|
+
return pure_python_blake3(content)
|
|
239
|
+
|
|
240
|
+
def prefix(self) -> str:
|
|
241
|
+
return BLAKE3_HASH_PREFIX
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Sha256Provider:
|
|
245
|
+
"""Default SHA-256 hash provider."""
|
|
246
|
+
|
|
247
|
+
def hash(self, content: bytes) -> str:
|
|
248
|
+
return hashlib.sha256(content).hexdigest()
|
|
249
|
+
|
|
250
|
+
def prefix(self) -> str:
|
|
251
|
+
return SHA256_HASH_PREFIX
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Md5Provider:
|
|
255
|
+
"""MD5 hash provider (weakest in the hierarchy, for legacy compatibility)."""
|
|
256
|
+
|
|
257
|
+
def hash(self, content: bytes) -> str:
|
|
258
|
+
return hashlib.md5(content).hexdigest()
|
|
259
|
+
|
|
260
|
+
def prefix(self) -> str:
|
|
261
|
+
return "md5:"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Sha1Provider:
|
|
265
|
+
"""SHA-1 hash provider (legacy, for migration path compatibility)."""
|
|
266
|
+
|
|
267
|
+
def hash(self, content: bytes) -> str:
|
|
268
|
+
return hashlib.sha1(content).hexdigest()
|
|
269
|
+
|
|
270
|
+
def prefix(self) -> str:
|
|
271
|
+
return "sha1:"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class Sha512Provider:
|
|
275
|
+
"""SHA-512 hash provider (stronger than SHA-256)."""
|
|
276
|
+
|
|
277
|
+
def hash(self, content: bytes) -> str:
|
|
278
|
+
return hashlib.sha512(content).hexdigest()
|
|
279
|
+
|
|
280
|
+
def prefix(self) -> str:
|
|
281
|
+
return "sha512:"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Shake256Provider:
|
|
285
|
+
"""Keccak SHAKE-256 provider with configurable output bit-length (custom-N).
|
|
286
|
+
|
|
287
|
+
Uses the SHAKE-256 extendable-output function (XOF) to produce a digest
|
|
288
|
+
of N bits. The prefix is ``custom-N:`` (e.g. ``custom-1024:``).
|
|
289
|
+
This provides future-proofing: if custom-N is exhausted, upgrade to
|
|
290
|
+
custom-2N by registering a new provider with doubled bit-length.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def __init__(self, bits: int) -> None:
|
|
294
|
+
self.bits = bits
|
|
295
|
+
self._prefix = f"custom-{bits}:"
|
|
296
|
+
|
|
297
|
+
def hash(self, content: bytes) -> str:
|
|
298
|
+
byte_len = (self.bits + 7) // 8
|
|
299
|
+
return hashlib.shake_256(content).hexdigest(byte_len)
|
|
300
|
+
|
|
301
|
+
def prefix(self) -> str:
|
|
302
|
+
return self._prefix
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Algorithm strength hierarchy (weakest → strongest).
|
|
306
|
+
STRENGTH_HIERARCHY: list[str] = [
|
|
307
|
+
"md5:",
|
|
308
|
+
"sha1:",
|
|
309
|
+
"sha256:",
|
|
310
|
+
"blake3:",
|
|
311
|
+
"sha512:",
|
|
312
|
+
"custom-1024:",
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def next_stronger_algorithm(current: str) -> str:
|
|
317
|
+
"""Recommend the next stronger algorithm in the hierarchy.
|
|
318
|
+
|
|
319
|
+
If the current algorithm is ``custom-N``, returns ``custom-2N``
|
|
320
|
+
(recursive doubling). If not in the hierarchy, returns ``blake3:``.
|
|
321
|
+
"""
|
|
322
|
+
if current.startswith("custom-") and current.endswith(":"):
|
|
323
|
+
num_str = current[len("custom-"):-1]
|
|
324
|
+
try:
|
|
325
|
+
doubled = int(num_str) * 2
|
|
326
|
+
return f"custom-{doubled}:"
|
|
327
|
+
except ValueError:
|
|
328
|
+
pass
|
|
329
|
+
try:
|
|
330
|
+
pos = STRENGTH_HIERARCHY.index(current)
|
|
331
|
+
if pos + 1 < len(STRENGTH_HIERARCHY):
|
|
332
|
+
return STRENGTH_HIERARCHY[pos + 1]
|
|
333
|
+
except ValueError:
|
|
334
|
+
pass
|
|
335
|
+
return DEFAULT_HASH_PREFIX
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def register_custom_provider(bits: int) -> None:
|
|
339
|
+
"""Register a custom-N (SHAKE-256) provider with the given bit-length."""
|
|
340
|
+
register_provider(Shake256Provider(bits))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# Provider registry — maps algorithm prefix → provider instance
|
|
344
|
+
_PROVIDER_REGISTRY: dict[str, HashProvider] = {}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def register_provider(provider: HashProvider) -> None:
|
|
348
|
+
"""Register a new hash provider under its algorithm prefix."""
|
|
349
|
+
_PROVIDER_REGISTRY[provider.prefix()] = provider
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def list_algorithms() -> list[str]:
|
|
353
|
+
"""List all registered algorithm prefixes."""
|
|
354
|
+
return list(_PROVIDER_REGISTRY.keys())
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# Register the default providers at import time
|
|
358
|
+
register_provider(Blake3Provider())
|
|
359
|
+
register_provider(Sha256Provider())
|
|
360
|
+
register_provider(Md5Provider())
|
|
361
|
+
register_provider(Sha1Provider())
|
|
362
|
+
register_provider(Sha512Provider())
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ── Hash configuration policy (Step 4) ─────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
@dataclass
|
|
368
|
+
class HashConfig:
|
|
369
|
+
"""Configuration policy for hash algorithm selection and collision handling.
|
|
370
|
+
|
|
371
|
+
Attributes:
|
|
372
|
+
active_algorithm: Algorithm prefix for new content (e.g. 'blake3:').
|
|
373
|
+
collision_threshold: Number of collisions before triggering an upgrade.
|
|
374
|
+
fallback_order: Ordered list of fallback algorithm prefixes.
|
|
375
|
+
"""
|
|
376
|
+
active_algorithm: str = BLAKE3_HASH_PREFIX
|
|
377
|
+
collision_threshold: int = DEFAULT_COLLISION_THRESHOLD
|
|
378
|
+
fallback_order: list[str] = field(default_factory=lambda: [BLAKE3_HASH_PREFIX, SHA256_HASH_PREFIX])
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _load_hash_config() -> HashConfig:
|
|
382
|
+
"""Load hash configuration from environment variables.
|
|
383
|
+
|
|
384
|
+
Checks ``CLM_HASH_CONFIG`` (format: ``active|threshold|fallback1,fallback2``).
|
|
385
|
+
If not present, checks ``CLM_HASH_ALGO`` or ``MCARD_HASH_ALGORITHM``.
|
|
386
|
+
Defaults to ``blake3:`` with fallback ``["blake3:", "sha256:"]``.
|
|
387
|
+
"""
|
|
388
|
+
raw = os.environ.get("CLM_HASH_CONFIG", "")
|
|
389
|
+
if raw:
|
|
390
|
+
parts = raw.split("|", 2)
|
|
391
|
+
if len(parts) >= 2:
|
|
392
|
+
active = parts[0] if parts[0] else BLAKE3_HASH_PREFIX
|
|
393
|
+
if not active.endswith(":"):
|
|
394
|
+
active = f"{active}:"
|
|
395
|
+
try:
|
|
396
|
+
threshold = int(parts[1])
|
|
397
|
+
except ValueError:
|
|
398
|
+
threshold = DEFAULT_COLLISION_THRESHOLD
|
|
399
|
+
if len(parts) == 3 and parts[2]:
|
|
400
|
+
fallbacks = [s if s.endswith(":") else f"{s}:" for s in parts[2].split(",") if s]
|
|
401
|
+
else:
|
|
402
|
+
fallbacks = [active, SHA256_HASH_PREFIX]
|
|
403
|
+
return HashConfig(
|
|
404
|
+
active_algorithm=active,
|
|
405
|
+
collision_threshold=threshold,
|
|
406
|
+
fallback_order=fallbacks,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
single_algo = os.environ.get("CLM_HASH_ALGO") or os.environ.get("MCARD_HASH_ALGORITHM")
|
|
410
|
+
if single_algo:
|
|
411
|
+
algo = single_algo.strip().lower()
|
|
412
|
+
if not algo.endswith(":"):
|
|
413
|
+
algo = f"{algo}:"
|
|
414
|
+
return HashConfig(
|
|
415
|
+
active_algorithm=algo,
|
|
416
|
+
collision_threshold=DEFAULT_COLLISION_THRESHOLD,
|
|
417
|
+
fallback_order=[algo, SHA256_HASH_PREFIX] if algo != SHA256_HASH_PREFIX else [SHA256_HASH_PREFIX],
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return HashConfig()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
#: Global hash configuration, loaded once from environment.
|
|
424
|
+
_HASH_CONFIG: HashConfig = _load_hash_config()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_hash_config() -> HashConfig:
|
|
428
|
+
"""Get the current hash configuration."""
|
|
429
|
+
return _HASH_CONFIG
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ── Prefix-based dispatch (Step 3) ─────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
def extract_hash_prefix(prefixed: str | bytes) -> str | None:
|
|
435
|
+
"""Extract the algorithm prefix from a stored hash string.
|
|
436
|
+
|
|
437
|
+
Returns the prefix (including ':') or None if no prefix is found.
|
|
438
|
+
"""
|
|
439
|
+
if isinstance(prefixed, bytes):
|
|
440
|
+
prefixed = prefixed.decode("utf-8", errors="replace")
|
|
441
|
+
pos = prefixed.find(":")
|
|
442
|
+
if pos >= 0:
|
|
443
|
+
return prefixed[: pos + 1]
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def verify_hash(stored_hash: str | bytes, content: str | bytes) -> bool:
|
|
448
|
+
"""Verify a stored hash against content by dispatching to the correct provider.
|
|
449
|
+
|
|
450
|
+
Parses the algorithm prefix from ``stored_hash``, looks up the
|
|
451
|
+
corresponding provider, and checks whether the content matches.
|
|
452
|
+
|
|
453
|
+
Returns True if the hash matches, False otherwise.
|
|
454
|
+
"""
|
|
455
|
+
sh_str = stored_hash.decode("utf-8", errors="replace") if isinstance(stored_hash, bytes) else str(stored_hash)
|
|
456
|
+
ct_str = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else str(content)
|
|
457
|
+
if ":" not in sh_str and ":" in ct_str:
|
|
458
|
+
stored_hash, content = content, stored_hash
|
|
459
|
+
sh_str, ct_str = ct_str, sh_str
|
|
460
|
+
|
|
461
|
+
prefix = extract_hash_prefix(stored_hash) or HASH_PREFIX
|
|
462
|
+
provider = _PROVIDER_REGISTRY.get(prefix)
|
|
463
|
+
if provider is None:
|
|
464
|
+
_logger.warning(f"Unknown hash algorithm prefix: {prefix} (registered: {list_algorithms()})")
|
|
465
|
+
return False
|
|
466
|
+
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
|
|
467
|
+
computed = provider.hash(content_bytes)
|
|
468
|
+
stored_raw = strip_hash_prefix(stored_hash)
|
|
469
|
+
return computed == stored_raw
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ── Collision detection (Step 5) ───────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class CollisionCheckResult(enum.Enum):
|
|
476
|
+
"""Result of a collision check."""
|
|
477
|
+
NO_COLLISION = "no_collision"
|
|
478
|
+
COLLISION = "collision"
|
|
479
|
+
THRESHOLD_EXCEEDED = "threshold_exceeded"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def check_collision(
|
|
483
|
+
hash_value: str,
|
|
484
|
+
new_content: bytes,
|
|
485
|
+
existing_content: bytes | None,
|
|
486
|
+
collision_count: int = 0,
|
|
487
|
+
) -> tuple[CollisionCheckResult, int]:
|
|
488
|
+
"""Check whether a hash collision has occurred.
|
|
489
|
+
|
|
490
|
+
Callers should invoke this *before* inserting a new card into the database.
|
|
491
|
+
If ``existing_content`` is not None and differs from ``new_content``, a
|
|
492
|
+
collision is recorded. When the collision count reaches the configured
|
|
493
|
+
threshold, ``THRESHOLD_EXCEEDED`` is returned.
|
|
494
|
+
|
|
495
|
+
Returns a tuple of (result, new_collision_count).
|
|
496
|
+
"""
|
|
497
|
+
if existing_content is None or existing_content == new_content:
|
|
498
|
+
return CollisionCheckResult.NO_COLLISION, collision_count
|
|
499
|
+
|
|
500
|
+
count = collision_count + 1
|
|
501
|
+
cfg = get_hash_config()
|
|
502
|
+
if count >= cfg.collision_threshold:
|
|
503
|
+
return CollisionCheckResult.THRESHOLD_EXCEEDED, count
|
|
504
|
+
return CollisionCheckResult.COLLISION, count
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def recommended_fallback_algorithm() -> str:
|
|
508
|
+
"""Get the recommended fallback algorithm when threshold is exceeded."""
|
|
509
|
+
cfg = get_hash_config()
|
|
510
|
+
for algo in cfg.fallback_order:
|
|
511
|
+
if algo != cfg.active_algorithm and algo in _PROVIDER_REGISTRY:
|
|
512
|
+
return algo
|
|
513
|
+
return cfg.active_algorithm
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ── Convenience functions ──────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def clm_hash_string(content: str | bytes) -> str:
|
|
520
|
+
"""Compute the content hash of a string or bytes using the active provider.
|
|
521
|
+
|
|
522
|
+
Dispatches dynamically to the registered provider matching the active algorithm.
|
|
523
|
+
All call sites across the codebase route through this function.
|
|
524
|
+
"""
|
|
525
|
+
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
|
|
526
|
+
active = get_hash_config().active_algorithm
|
|
527
|
+
provider = _PROVIDER_REGISTRY.get(active)
|
|
528
|
+
if provider is not None:
|
|
529
|
+
return provider.hash(content_bytes)
|
|
530
|
+
return hashlib.sha256(content_bytes).hexdigest()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def prefixed_hash(content: str | bytes) -> str:
|
|
534
|
+
"""Compute the prefixed content hash (e.g. ``blake3:<hex>`` or ``sha256:<hex>``)."""
|
|
535
|
+
cfg = get_hash_config()
|
|
536
|
+
provider = _PROVIDER_REGISTRY.get(cfg.active_algorithm)
|
|
537
|
+
pfx = provider.prefix() if provider is not None else HASH_PREFIX
|
|
538
|
+
return f"{pfx}{clm_hash_string(content)}"
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def prefixed_hash_raw(raw_hex: str) -> str:
|
|
542
|
+
"""Prepend the active algorithm prefix to an already-computed raw hex digest."""
|
|
543
|
+
cfg = get_hash_config()
|
|
544
|
+
return f"{cfg.active_algorithm}{raw_hex}"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def strip_hash_prefix(prefixed: str | bytes) -> str:
|
|
548
|
+
"""Strip the algorithm prefix from a hash string.
|
|
549
|
+
|
|
550
|
+
If the hash starts with a known prefix (e.g. ``blake3:`` or ``sha256:``),
|
|
551
|
+
the unprefixed hex is returned. Otherwise the input is returned as-is.
|
|
552
|
+
"""
|
|
553
|
+
if isinstance(prefixed, bytes):
|
|
554
|
+
prefixed = prefixed.decode("utf-8", errors="replace")
|
|
555
|
+
active = get_hash_config().active_algorithm
|
|
556
|
+
if prefixed.startswith(active):
|
|
557
|
+
return prefixed[len(active):]
|
|
558
|
+
if prefixed.startswith(DEFAULT_HASH_PREFIX):
|
|
559
|
+
return prefixed[len(DEFAULT_HASH_PREFIX):]
|
|
560
|
+
if prefixed.startswith(SHA256_HASH_PREFIX):
|
|
561
|
+
return prefixed[len(SHA256_HASH_PREFIX):]
|
|
562
|
+
# Generic fallback: strip any ``<algo>:`` prefix
|
|
563
|
+
if ":" in prefixed:
|
|
564
|
+
return prefixed.split(":", 1)[1]
|
|
565
|
+
return prefixed
|
|
566
|
+
|
|
567
|
+
def clm_hash_file(filepath: str) -> str:
|
|
568
|
+
"""Compute the content hash of a file after normalizing line endings."""
|
|
569
|
+
if not os.path.exists(filepath):
|
|
570
|
+
return ""
|
|
571
|
+
with open(filepath, "rb") as f:
|
|
572
|
+
raw = f.read()
|
|
573
|
+
normalized = normalize_line_endings_bytes(raw)
|
|
574
|
+
return clm_hash_string(normalized)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ── Content-equivalence operator (Gap 2) ──────────────────────────────────
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def content_equivalent(db_path: str, hash_a: str, hash_b: str) -> bool:
|
|
581
|
+
"""Check whether two hashes point to the same content in the CAS.
|
|
582
|
+
|
|
583
|
+
This is the Python equivalent of the shell ``clm_content_equivalent`` operator.
|
|
584
|
+
Two hashes generated by different algorithms (e.g. ``sha256:abc`` and
|
|
585
|
+
``sha512:def``) will never match as strings, but they may point to the same
|
|
586
|
+
underlying content. This function resolves both hashes to their stored
|
|
587
|
+
content and compares the raw bytes.
|
|
588
|
+
|
|
589
|
+
Returns ``True`` if both hashes exist and point to identical content,
|
|
590
|
+
``False`` otherwise.
|
|
591
|
+
"""
|
|
592
|
+
if hash_a == hash_b:
|
|
593
|
+
return True
|
|
594
|
+
import sqlite3
|
|
595
|
+
conn = sqlite3.connect(db_path)
|
|
596
|
+
try:
|
|
597
|
+
cursor = conn.cursor()
|
|
598
|
+
cursor.execute("SELECT content FROM card WHERE hash = ?", (hash_a,))
|
|
599
|
+
row_a = cursor.fetchone()
|
|
600
|
+
cursor.execute("SELECT content FROM card WHERE hash = ?", (hash_b,))
|
|
601
|
+
row_b = cursor.fetchone()
|
|
602
|
+
if row_a is None or row_b is None:
|
|
603
|
+
return False
|
|
604
|
+
return row_a[0] == row_b[0]
|
|
605
|
+
finally:
|
|
606
|
+
conn.close()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# ── Handle migration lifecycle (Gap 3) ────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def migrate_handle_hash(db_path: str, handle: str, target_algo: str) -> str:
|
|
613
|
+
"""Migrate a handle to use a different hash algorithm.
|
|
614
|
+
|
|
615
|
+
This is the Python equivalent of the shell ``clm_migrate_handle_hash`` operator.
|
|
616
|
+
It resolves the handle to its current hash, retrieves the content from the
|
|
617
|
+
CAS, computes a new hash under the target algorithm, and transactionally
|
|
618
|
+
inserts the new card, records handle history, and updates the handle pointer.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
db_path: Path to the content_memory SQLite database.
|
|
622
|
+
handle: The handle name to migrate (e.g. ``test:migration_handle``).
|
|
623
|
+
target_algo: Target algorithm prefix (e.g. ``sha256:``, ``sha512:``,
|
|
624
|
+
``custom-1024:``). Must be a registered provider.
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
The new hash string (with algorithm prefix).
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
ValueError: If the handle is not found, content is missing, or the
|
|
631
|
+
target algorithm is not registered.
|
|
632
|
+
"""
|
|
633
|
+
# Validate target algorithm
|
|
634
|
+
algo = target_algo.rstrip(":")
|
|
635
|
+
if algo not in _PROVIDER_REGISTRY and f"{algo}:" not in _PROVIDER_REGISTRY:
|
|
636
|
+
# Try with colon
|
|
637
|
+
if target_algo in _PROVIDER_REGISTRY:
|
|
638
|
+
algo = target_algo
|
|
639
|
+
else:
|
|
640
|
+
raise ValueError(
|
|
641
|
+
f"Unsupported target algorithm: '{target_algo}' "
|
|
642
|
+
f"(registered: {list_algorithms()})"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
import sqlite3
|
|
646
|
+
from datetime import datetime, timezone
|
|
647
|
+
|
|
648
|
+
conn = sqlite3.connect(db_path)
|
|
649
|
+
try:
|
|
650
|
+
cursor = conn.cursor()
|
|
651
|
+
|
|
652
|
+
# 1. Resolve handle to get current hash
|
|
653
|
+
cursor.execute(
|
|
654
|
+
"SELECT current_hash FROM handle_registry WHERE handle = ?",
|
|
655
|
+
(handle,),
|
|
656
|
+
)
|
|
657
|
+
row = cursor.fetchone()
|
|
658
|
+
if row is None:
|
|
659
|
+
raise ValueError(f"Handle '{handle}' not found in handle_registry")
|
|
660
|
+
current_hash = row[0]
|
|
661
|
+
|
|
662
|
+
# 2. Retrieve existing content
|
|
663
|
+
cursor.execute(
|
|
664
|
+
"SELECT content FROM card WHERE hash = ?", (current_hash,)
|
|
665
|
+
)
|
|
666
|
+
row = cursor.fetchone()
|
|
667
|
+
if row is None:
|
|
668
|
+
raise ValueError(
|
|
669
|
+
f"Content not found in CAS for hash '{current_hash}'"
|
|
670
|
+
)
|
|
671
|
+
content = row[0]
|
|
672
|
+
|
|
673
|
+
# 3. Compute new hash under target algorithm
|
|
674
|
+
provider_key = target_algo if target_algo in _PROVIDER_REGISTRY else f"{algo}:"
|
|
675
|
+
provider = _PROVIDER_REGISTRY.get(provider_key)
|
|
676
|
+
if provider is None:
|
|
677
|
+
raise ValueError(f"Provider not found for algorithm '{target_algo}'")
|
|
678
|
+
new_raw_hash = provider.hash(content if isinstance(content, bytes) else content.encode("utf-8"))
|
|
679
|
+
new_hash = f"{provider.prefix()}{new_raw_hash}"
|
|
680
|
+
|
|
681
|
+
# 4. Transactional update with retry for WAL lock contention
|
|
682
|
+
now_utc = datetime.now(timezone.utc).isoformat()
|
|
683
|
+
g_time = f"{algo}|{now_utc}|UTC"
|
|
684
|
+
|
|
685
|
+
import time as _time
|
|
686
|
+
max_attempts = 3
|
|
687
|
+
last_err: Exception | None = None
|
|
688
|
+
for attempt in range(max_attempts):
|
|
689
|
+
try:
|
|
690
|
+
conn.execute("BEGIN TRANSACTION")
|
|
691
|
+
conn.execute(
|
|
692
|
+
"INSERT OR IGNORE INTO card (hash, content, g_time) VALUES (?, ?, ?)",
|
|
693
|
+
(new_hash, content, g_time),
|
|
694
|
+
)
|
|
695
|
+
conn.execute(
|
|
696
|
+
"INSERT INTO handle_history (handle, previous_hash, changed_at) VALUES (?, ?, ?)",
|
|
697
|
+
(handle, current_hash, now_utc),
|
|
698
|
+
)
|
|
699
|
+
conn.execute(
|
|
700
|
+
"UPDATE handle_registry SET current_hash = ?, updated_at = ? WHERE handle = ?",
|
|
701
|
+
(new_hash, now_utc, handle),
|
|
702
|
+
)
|
|
703
|
+
conn.execute("COMMIT")
|
|
704
|
+
|
|
705
|
+
_logger.info(
|
|
706
|
+
f"migrate_handle_hash: migrated '{handle}' from {current_hash} to {new_hash}"
|
|
707
|
+
)
|
|
708
|
+
return new_hash
|
|
709
|
+
except Exception as retry_err:
|
|
710
|
+
last_err = retry_err
|
|
711
|
+
with contextlib.suppress(Exception):
|
|
712
|
+
conn.execute("ROLLBACK")
|
|
713
|
+
if attempt + 1 < max_attempts:
|
|
714
|
+
_time.sleep(0.2)
|
|
715
|
+
|
|
716
|
+
raise RuntimeError(
|
|
717
|
+
f"Transaction failed after {max_attempts} attempts: {last_err}"
|
|
718
|
+
)
|
|
719
|
+
finally:
|
|
720
|
+
conn.close()
|
|
721
|
+
|
|
722
|
+
def resolve_clm_path(clm_root: str, ref: str) -> str:
|
|
723
|
+
"""Resolve a relative CLM path referencing files/directories to absolute path."""
|
|
724
|
+
if not ref:
|
|
725
|
+
return ref
|
|
726
|
+
if os.path.isabs(ref):
|
|
727
|
+
return ref
|
|
728
|
+
if os.path.exists(ref):
|
|
729
|
+
return os.path.abspath(ref)
|
|
730
|
+
|
|
731
|
+
cand = os.path.join(clm_root, ref)
|
|
732
|
+
if os.path.exists(cand):
|
|
733
|
+
return os.path.abspath(cand)
|
|
734
|
+
|
|
735
|
+
project_root = os.path.dirname(clm_root)
|
|
736
|
+
cand = os.path.join(project_root, ref)
|
|
737
|
+
if os.path.exists(cand):
|
|
738
|
+
return os.path.abspath(cand)
|
|
739
|
+
|
|
740
|
+
if ref.startswith("clm/"):
|
|
741
|
+
stripped = ref[4:]
|
|
742
|
+
cand = os.path.join(clm_root, stripped)
|
|
743
|
+
if os.path.exists(cand):
|
|
744
|
+
return os.path.abspath(cand)
|
|
745
|
+
|
|
746
|
+
return ref
|
|
747
|
+
|
|
748
|
+
def detect_host_os() -> str:
|
|
749
|
+
"""Detect and return host OS platform in lowercase (linux, darwin, windows, etc.)."""
|
|
750
|
+
raw_os = platform.system().lower()
|
|
751
|
+
if "linux" in raw_os:
|
|
752
|
+
return "linux"
|
|
753
|
+
elif "darwin" in raw_os:
|
|
754
|
+
return "darwin"
|
|
755
|
+
elif "windows" in raw_os:
|
|
756
|
+
return "windows"
|
|
757
|
+
return "unknown"
|
|
758
|
+
|
|
759
|
+
def is_docker_live(port: int) -> bool:
|
|
760
|
+
"""Check if docker daemon is responsive and a service is open at localhost:port."""
|
|
761
|
+
if os.environ.get("CLM_ZERO_FS", "").lower() == "true":
|
|
762
|
+
return False
|
|
763
|
+
try:
|
|
764
|
+
import socket
|
|
765
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
766
|
+
s.settimeout(0.5)
|
|
767
|
+
s.connect(("127.0.0.1", port))
|
|
768
|
+
s.close()
|
|
769
|
+
except Exception:
|
|
770
|
+
return False
|
|
771
|
+
try:
|
|
772
|
+
import subprocess
|
|
773
|
+
proc = subprocess.Popen(["docker", "info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
774
|
+
try:
|
|
775
|
+
proc.communicate(timeout=2)
|
|
776
|
+
except subprocess.TimeoutExpired:
|
|
777
|
+
proc.kill()
|
|
778
|
+
proc.communicate()
|
|
779
|
+
return False
|
|
780
|
+
if proc.returncode != 0:
|
|
781
|
+
return False
|
|
782
|
+
except Exception:
|
|
783
|
+
return False
|
|
784
|
+
|
|
785
|
+
if port == 9000:
|
|
786
|
+
try:
|
|
787
|
+
proc = subprocess.Popen(["docker", "exec", "minio_server", "mc", "alias", "list", "local"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
788
|
+
try:
|
|
789
|
+
proc.communicate(timeout=2)
|
|
790
|
+
except subprocess.TimeoutExpired:
|
|
791
|
+
proc.kill()
|
|
792
|
+
proc.communicate()
|
|
793
|
+
return False
|
|
794
|
+
return proc.returncode == 0
|
|
795
|
+
except Exception:
|
|
796
|
+
return False
|
|
797
|
+
elif port == 5001:
|
|
798
|
+
try:
|
|
799
|
+
proc = subprocess.Popen(["docker", "exec", "ipfs_host", "ipfs", "id"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
800
|
+
try:
|
|
801
|
+
proc.communicate(timeout=2)
|
|
802
|
+
except subprocess.TimeoutExpired:
|
|
803
|
+
proc.kill()
|
|
804
|
+
proc.communicate()
|
|
805
|
+
return False
|
|
806
|
+
return proc.returncode == 0
|
|
807
|
+
except Exception:
|
|
808
|
+
return False
|
|
809
|
+
|
|
810
|
+
return True
|
|
811
|
+
|
|
812
|
+
def get_bash_command() -> str:
|
|
813
|
+
"""Resolve the bash executable path on the current platform, preferring Git Bash on Windows."""
|
|
814
|
+
if sys.platform == "win32":
|
|
815
|
+
bash_env = os.environ.get("BASH")
|
|
816
|
+
if bash_env and os.path.exists(bash_env):
|
|
817
|
+
return bash_env
|
|
818
|
+
git_bash = r"C:\Program Files\Git\bin\bash.exe"
|
|
819
|
+
if os.path.exists(git_bash):
|
|
820
|
+
return git_bash
|
|
821
|
+
return "bash"
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def verify_file_hash(filepath: str, expected_hash: str) -> bytes:
|
|
825
|
+
"""Read a file, normalize line endings, verify SHA-256, and return normalized content."""
|
|
826
|
+
if not os.path.exists(filepath):
|
|
827
|
+
sys.exit(f"FATAL: Manifest missing at {filepath}")
|
|
828
|
+
|
|
829
|
+
with open(filepath, "rb") as fh:
|
|
830
|
+
raw = fh.read()
|
|
831
|
+
|
|
832
|
+
normalized = normalize_line_endings_bytes(raw)
|
|
833
|
+
computed = hashlib.sha256(normalized).hexdigest()
|
|
834
|
+
|
|
835
|
+
if computed != expected_hash:
|
|
836
|
+
_logger.error(" INTEGRITY FAILURE: file content has changed")
|
|
837
|
+
_logger.error(f" Expected: {expected_hash}")
|
|
838
|
+
_logger.error(f" Actual: {computed}")
|
|
839
|
+
sys.exit(1)
|
|
840
|
+
|
|
841
|
+
return normalized
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def extract_python_script_from_yaml(normalized_content: bytes) -> str:
|
|
845
|
+
"""Extract the indented Python bootstrap script from a clmEval.yaml payload."""
|
|
846
|
+
lines = normalized_content.decode("utf-8").splitlines()
|
|
847
|
+
in_python_block = False
|
|
848
|
+
in_script = False
|
|
849
|
+
script_lines = []
|
|
850
|
+
indent_len = None
|
|
851
|
+
|
|
852
|
+
for line in lines:
|
|
853
|
+
stripped = line.strip()
|
|
854
|
+
if not in_python_block:
|
|
855
|
+
if stripped == "- type: python":
|
|
856
|
+
in_python_block = True
|
|
857
|
+
else:
|
|
858
|
+
if stripped.startswith("- type:"):
|
|
859
|
+
break
|
|
860
|
+
if stripped.startswith("balanced_exp:"):
|
|
861
|
+
break
|
|
862
|
+
if not in_script:
|
|
863
|
+
if stripped.startswith("script:"):
|
|
864
|
+
in_script = True
|
|
865
|
+
else:
|
|
866
|
+
if line:
|
|
867
|
+
leading_spaces = len(line) - len(line.lstrip(' '))
|
|
868
|
+
if indent_len is None:
|
|
869
|
+
indent_len = leading_spaces
|
|
870
|
+
if leading_spaces < indent_len:
|
|
871
|
+
break
|
|
872
|
+
if indent_len is not None:
|
|
873
|
+
if len(line) >= indent_len:
|
|
874
|
+
script_lines.append(line[indent_len:])
|
|
875
|
+
else:
|
|
876
|
+
script_lines.append("")
|
|
877
|
+
else:
|
|
878
|
+
if len(line) >= 8:
|
|
879
|
+
script_lines.append(line[8:])
|
|
880
|
+
else:
|
|
881
|
+
script_lines.append("")
|
|
882
|
+
|
|
883
|
+
if not script_lines:
|
|
884
|
+
sys.exit("FATAL: Could not extract Python bootstrap script.")
|
|
885
|
+
|
|
886
|
+
return "\n".join(script_lines)
|