zikaron 0.1.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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""`meta`'s five required keys, and the `reindexing` sentinel that is deliberately not one of them.
|
|
2
|
+
|
|
3
|
+
`schema.md` §"`meta` — the store-coupled values, and the initialization contract" is normative.
|
|
4
|
+
Two facts from it drive this module's shape: validation happens **twice** — written at creation,
|
|
5
|
+
parsed and range-checked on **every** open — and a required key that is missing, unparseable or
|
|
6
|
+
out of range on open is a fatal `bad_config`, never a silent fall-back to a default. `reindexing`
|
|
7
|
+
is a transient key whose *absence* is normal, so it is exempt from that required-key validation
|
|
8
|
+
and handled by its own present/absent check instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Final
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from zikaron.core.errors import BadConfigSource, ErrorCode, ZikaronError
|
|
17
|
+
|
|
18
|
+
#: The `meta` row key naming the schema this build's tables and invariants implement. Compared
|
|
19
|
+
#: against `zikaron.core.errors.ERROR_SPECS[SCHEMA_INCOMPATIBLE].data_fields`'s fixed `supported`
|
|
20
|
+
#: value by a test, so this module's schema version is never a second, driftable statement of it.
|
|
21
|
+
SCHEMA_VERSION_KEY: Final = "schema_version"
|
|
22
|
+
STORE_ID_KEY: Final = "store_id"
|
|
23
|
+
EMBED_MODEL_KEY: Final = "embed_model"
|
|
24
|
+
EMBED_DIM_KEY: Final = "embed_dim"
|
|
25
|
+
CHUNK_MAX_TOKENS_KEY: Final = "chunk_max_tokens"
|
|
26
|
+
|
|
27
|
+
#: The transient sentinel `schema.md` explicitly excludes from required-key validation: normal
|
|
28
|
+
#: absence, abnormal presence. Its value is the ISO-8601 instant the unavailable window opened.
|
|
29
|
+
REINDEXING_KEY: Final = "reindexing"
|
|
30
|
+
|
|
31
|
+
#: The five keys required on every open, in the order this module reads and validates them.
|
|
32
|
+
REQUIRED_KEYS: Final[tuple[str, ...]] = (
|
|
33
|
+
SCHEMA_VERSION_KEY,
|
|
34
|
+
STORE_ID_KEY,
|
|
35
|
+
EMBED_MODEL_KEY,
|
|
36
|
+
EMBED_DIM_KEY,
|
|
37
|
+
CHUNK_MAX_TOKENS_KEY,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_CHUNK_MAX_TOKENS_MIN: Final = 64
|
|
41
|
+
_CHUNK_MAX_TOKENS_MAX: Final = 8192
|
|
42
|
+
_UUID4_STRING_LENGTH: Final = 36
|
|
43
|
+
_UUID_VERSION_4: Final = 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class StoreMeta:
|
|
48
|
+
"""The five required `meta` values, parsed and range-checked.
|
|
49
|
+
|
|
50
|
+
Constructing one *is* passing validation: every field has already been read as its typed
|
|
51
|
+
value and shown to lie in its stated range, so a caller holding a `StoreMeta` need not
|
|
52
|
+
re-check any of them.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
schema_version: int
|
|
56
|
+
store_id: str
|
|
57
|
+
embed_model: str
|
|
58
|
+
embed_dim: int
|
|
59
|
+
chunk_max_tokens: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bad_meta(key: str, value: str, expected: str) -> ZikaronError:
|
|
63
|
+
return ZikaronError(
|
|
64
|
+
ErrorCode.BAD_CONFIG,
|
|
65
|
+
source=BadConfigSource.META,
|
|
66
|
+
key=key,
|
|
67
|
+
value=value,
|
|
68
|
+
expected=expected,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _require_int(raw: Mapping[str, str], key: str, expected: str) -> int:
|
|
73
|
+
if key not in raw:
|
|
74
|
+
raise _bad_meta(key, "<missing>", expected)
|
|
75
|
+
text = raw[key]
|
|
76
|
+
try:
|
|
77
|
+
return int(text)
|
|
78
|
+
except ValueError as error:
|
|
79
|
+
raise _bad_meta(key, text, expected) from error
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _require_string(raw: Mapping[str, str], key: str, expected: str) -> str:
|
|
83
|
+
if key not in raw:
|
|
84
|
+
raise _bad_meta(key, "<missing>", expected)
|
|
85
|
+
return raw[key]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_and_validate(raw: Mapping[str, str]) -> StoreMeta:
|
|
89
|
+
"""Turn every `meta` row's text value into `StoreMeta`, or raise naming the first bad key.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
raw: every `meta` row's text value, keyed by `key`. Extra keys (an unknown setting a
|
|
93
|
+
newer writer left behind) are tolerated silently, per `schema.md`'s forward-
|
|
94
|
+
compatibility rule for a *supported* schema version.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ZikaronError: `BAD_CONFIG` with `source='meta'`, naming the first required key found
|
|
98
|
+
missing, unparseable, or out of its stated range. There is no partial `StoreMeta` to
|
|
99
|
+
return on failure, per `schema.md`'s "fatal store error, not a fall-back" rule.
|
|
100
|
+
"""
|
|
101
|
+
schema_version = _require_int(raw, SCHEMA_VERSION_KEY, "int >= 1")
|
|
102
|
+
if schema_version < 1:
|
|
103
|
+
raise _bad_meta(SCHEMA_VERSION_KEY, str(schema_version), "int >= 1")
|
|
104
|
+
store_id = _require_string(raw, STORE_ID_KEY, "uuid4 string, 36 chars")
|
|
105
|
+
if len(store_id) != _UUID4_STRING_LENGTH:
|
|
106
|
+
raise _bad_meta(STORE_ID_KEY, store_id, "uuid4 string, 36 chars")
|
|
107
|
+
try:
|
|
108
|
+
parsed_store_id = UUID(store_id)
|
|
109
|
+
except ValueError as error:
|
|
110
|
+
raise _bad_meta(STORE_ID_KEY, store_id, "uuid4 string, 36 chars") from error
|
|
111
|
+
if parsed_store_id.version != _UUID_VERSION_4:
|
|
112
|
+
raise _bad_meta(STORE_ID_KEY, store_id, "uuid4 string, 36 chars")
|
|
113
|
+
embed_model = _require_string(raw, EMBED_MODEL_KEY, "non-empty string")
|
|
114
|
+
if embed_model == "":
|
|
115
|
+
raise _bad_meta(EMBED_MODEL_KEY, embed_model, "non-empty string")
|
|
116
|
+
embed_dim = _require_int(raw, EMBED_DIM_KEY, "int >= 1")
|
|
117
|
+
if embed_dim < 1:
|
|
118
|
+
raise _bad_meta(EMBED_DIM_KEY, str(embed_dim), "int >= 1")
|
|
119
|
+
chunk_max_tokens = _require_int(
|
|
120
|
+
raw, CHUNK_MAX_TOKENS_KEY, f"int {_CHUNK_MAX_TOKENS_MIN}-{_CHUNK_MAX_TOKENS_MAX}"
|
|
121
|
+
)
|
|
122
|
+
if not (_CHUNK_MAX_TOKENS_MIN <= chunk_max_tokens <= _CHUNK_MAX_TOKENS_MAX):
|
|
123
|
+
raise _bad_meta(
|
|
124
|
+
CHUNK_MAX_TOKENS_KEY,
|
|
125
|
+
str(chunk_max_tokens),
|
|
126
|
+
f"int {_CHUNK_MAX_TOKENS_MIN}-{_CHUNK_MAX_TOKENS_MAX}",
|
|
127
|
+
)
|
|
128
|
+
return StoreMeta(
|
|
129
|
+
schema_version=schema_version,
|
|
130
|
+
store_id=store_id,
|
|
131
|
+
embed_model=embed_model,
|
|
132
|
+
embed_dim=embed_dim,
|
|
133
|
+
chunk_max_tokens=chunk_max_tokens,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def defaults_at_creation(
|
|
138
|
+
schema_version: int, store_id: str, embed_model: str, embed_dim: int, chunk_max_tokens: int
|
|
139
|
+
) -> Mapping[str, str]:
|
|
140
|
+
"""Every required `meta` row's text value at store creation, in `REQUIRED_KEYS` order.
|
|
141
|
+
|
|
142
|
+
`schema.md` requires a fresh store to be "fully described" so nothing on open reads a
|
|
143
|
+
missing key — this is the write side of that promise, called once by `Store.create` after
|
|
144
|
+
every value it takes has already been validated by whoever assembled them.
|
|
145
|
+
"""
|
|
146
|
+
return {
|
|
147
|
+
SCHEMA_VERSION_KEY: str(schema_version),
|
|
148
|
+
STORE_ID_KEY: store_id,
|
|
149
|
+
EMBED_MODEL_KEY: embed_model,
|
|
150
|
+
EMBED_DIM_KEY: str(embed_dim),
|
|
151
|
+
CHUNK_MAX_TOKENS_KEY: str(chunk_max_tokens),
|
|
152
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""`0700`/`0600` enforcement for the store directory and the files inside it.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Filesystem security" is normative: `.zikaron/` and `.zikaron/knowledge/` are
|
|
4
|
+
mode `0700`, and `memory.db`, every `knowledge/<uuid4>.db`, and all their `-wal`/`-shm` siblings
|
|
5
|
+
are `0600`, created with an explicit umask rather
|
|
6
|
+
than inherited — SQLite creates the WAL/SHM files itself and will otherwise pick up whatever
|
|
7
|
+
umask the process happened to have. A directory found wider than `0700` is tightened rather than
|
|
8
|
+
refused, per the same table.
|
|
9
|
+
|
|
10
|
+
`core/knowledge/database.py` imports this module so a knowledge base gets the same modes, which
|
|
11
|
+
matters at least as much there: a corpus holds the verbatim text of every file indexed into it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from zikaron.core.errors import BadConfigSource, ErrorCode, ZikaronError
|
|
20
|
+
|
|
21
|
+
_STORE_DIR_MODE = 0o700
|
|
22
|
+
_STORE_FILE_MODE = 0o600
|
|
23
|
+
_STORE_FILE_UMASK = 0o077
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reject_symlinked_store_dir(store_dir: Path) -> None:
|
|
27
|
+
"""Refuse a store reached through a symlink anywhere in `store_dir`'s own path.
|
|
28
|
+
|
|
29
|
+
`architecture.md` §"Filesystem security": "`realpath` the store directory and require the
|
|
30
|
+
resolved parent to be the **scope directory** (D17 as amended — under Claude Code deliberately
|
|
31
|
+
not the spawning process's cwd)." *This quoted the pre-amendment wording — "the cwd" — which
|
|
32
|
+
D17 replaced, and then glossed the word the amendment had removed. One side of a two-way
|
|
33
|
+
pointer was updated and the other kept quoting the superseded text; the pointer guard cannot
|
|
34
|
+
see it, because the section name it resolves is intact.* That sentence states the rule from
|
|
35
|
+
`zikaron-service`'s own point of view, where the store path is *derived* from the caller's
|
|
36
|
+
scope directory in the first place (D17: the harness's own project directory where it names
|
|
37
|
+
one, else the working directory; the vetting below is unaffected either way, since it never
|
|
38
|
+
compares against an ambient cwd) — deriving `store_dir` is that caller's job, not this
|
|
39
|
+
function's, since `Store.create`/`Store.open` take `store_dir` as an explicit parameter and
|
|
40
|
+
have no way to know whether the caller's own cwd is the concept the caller meant by it. A
|
|
41
|
+
caller that resolved `store_dir` from somewhere other than its own cwd — a test, a tool
|
|
42
|
+
walking several stores — would be wrongly refused by comparing against the *ambient*
|
|
43
|
+
process cwd here, which is a fact about this function's environment rather than about the
|
|
44
|
+
path it was actually asked to open.
|
|
45
|
+
|
|
46
|
+
What this function *can* check, and what carries the rule's real security content
|
|
47
|
+
regardless of who derived the path: whether `store_dir` is reached through a symlink at
|
|
48
|
+
all, anywhere along its own length — `store_dir` itself, or any directory between it and
|
|
49
|
+
the filesystem root. A bare `store_dir.is_symlink()` check misses the case a symlinked
|
|
50
|
+
*ancestor* creates, e.g. `some-symlinked-parent/.zikaron`, where `.zikaron` itself is a
|
|
51
|
+
genuine directory but the path leading to it is not what it appears to be.
|
|
52
|
+
"""
|
|
53
|
+
current = store_dir if store_dir.is_absolute() else Path.cwd() / store_dir
|
|
54
|
+
while True:
|
|
55
|
+
if current.is_symlink():
|
|
56
|
+
raise ZikaronError(
|
|
57
|
+
ErrorCode.BAD_CONFIG,
|
|
58
|
+
source=BadConfigSource.DERIVED,
|
|
59
|
+
key="store_dir",
|
|
60
|
+
value=str(store_dir),
|
|
61
|
+
expected=f"no symlink anywhere in the path to it (found one at {current})",
|
|
62
|
+
)
|
|
63
|
+
parent = current.parent
|
|
64
|
+
if parent == current:
|
|
65
|
+
return
|
|
66
|
+
current = parent
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_store_dir(path: Path) -> None:
|
|
70
|
+
"""Create `path` at `0700` if absent, or tighten it to `0700` if it exists wider.
|
|
71
|
+
|
|
72
|
+
Explicit `mkdir(mode=...)` rather than a bare `mkdir()` followed by `chmod`, because a
|
|
73
|
+
process umask can otherwise narrow the mode `mkdir` was asked for and leave a gap between
|
|
74
|
+
what this function requested and what actually landed on disk.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ZikaronError: `BAD_CONFIG` if `path`, once resolved, is not reached through the current
|
|
78
|
+
directory alone — `architecture.md` §"Filesystem security" requires a store reached
|
|
79
|
+
through a symlinked `.zikaron`, or through a symlinked ancestor of it, to be
|
|
80
|
+
refused rather than followed, since the store's own `0700`/`0600` modes protect the
|
|
81
|
+
directory they are set on, not whatever a symlink happens to point at.
|
|
82
|
+
"""
|
|
83
|
+
_reject_symlinked_store_dir(path)
|
|
84
|
+
if path.is_dir():
|
|
85
|
+
current = path.stat().st_mode & 0o777
|
|
86
|
+
if current != _STORE_DIR_MODE:
|
|
87
|
+
path.chmod(_STORE_DIR_MODE)
|
|
88
|
+
return
|
|
89
|
+
path.mkdir(mode=_STORE_DIR_MODE, parents=True)
|
|
90
|
+
path.chmod(_STORE_DIR_MODE)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def restrictive_umask() -> Iterator[None]:
|
|
95
|
+
"""Force every file created in this block to land at `0600`, regardless of the process umask.
|
|
96
|
+
|
|
97
|
+
SQLite creates `memory.db`'s `-wal` and `-shm` siblings itself, outside any `open()` call
|
|
98
|
+
this module makes, so a umask set only around an explicit file creation would not reach
|
|
99
|
+
them. Wrapping the whole store-creation sequence in this umask is what does.
|
|
100
|
+
"""
|
|
101
|
+
previous = os.umask(_STORE_FILE_UMASK)
|
|
102
|
+
try:
|
|
103
|
+
yield
|
|
104
|
+
finally:
|
|
105
|
+
os.umask(previous)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def database_files(db_path: Path) -> tuple[Path, Path, Path]:
|
|
109
|
+
"""`db_path` and its two WAL siblings — the three files one open database actually is.
|
|
110
|
+
|
|
111
|
+
In WAL mode a database at rest is one file and a database in use is three, and every operation
|
|
112
|
+
that moves, removes or re-permissions one has to handle all of them. Enumerating them in one
|
|
113
|
+
place rather than at each call site is what keeps a caller from tightening `memory.db` while
|
|
114
|
+
leaving its `-wal` world-readable, or from unlinking a knowledge base and leaving its journal
|
|
115
|
+
behind for the next create at that path to inherit.
|
|
116
|
+
|
|
117
|
+
Returned in a fixed order — database, `-wal`, `-shm` — so a caller that unlinks them does so
|
|
118
|
+
in an order a reader can reason about, and named by suffix rather than by a hardcoded
|
|
119
|
+
`memory.db` so a knowledge-base database is the same three files by the same rule.
|
|
120
|
+
"""
|
|
121
|
+
return (
|
|
122
|
+
db_path,
|
|
123
|
+
db_path.with_name(db_path.name + "-wal"),
|
|
124
|
+
db_path.with_name(db_path.name + "-shm"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def enforce_store_file_mode(path: Path) -> None:
|
|
129
|
+
"""Tighten an existing store file to `0600` if it was created wider than that.
|
|
130
|
+
|
|
131
|
+
Called after store creation as a belt-and-suspenders check: `restrictive_umask` is what
|
|
132
|
+
should have produced `0600` in the first place, and this is what makes a wider mode a
|
|
133
|
+
correction rather than a silent gap if some code path ever creates the file outside it.
|
|
134
|
+
"""
|
|
135
|
+
if path.is_file():
|
|
136
|
+
current = path.stat().st_mode & 0o777
|
|
137
|
+
if current != _STORE_FILE_MODE:
|
|
138
|
+
path.chmod(_STORE_FILE_MODE)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def enforce_existing_store_permissions(store_dir: Path) -> None:
|
|
142
|
+
"""Tighten a pre-existing store's directory and database files, without creating anything.
|
|
143
|
+
|
|
144
|
+
`Store.open` calls this before touching the connection, so a store whose directory was left
|
|
145
|
+
at a wider mode by an older build — or by an operator's own `chmod` — is corrected on the
|
|
146
|
+
very next open rather than only ever being corrected at creation time, which is the one
|
|
147
|
+
moment `Store.create`'s own checks previously ran.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ZikaronError: `BAD_CONFIG` if `store_dir`, once resolved, is not reached through the
|
|
151
|
+
current directory alone, for the same reason `ensure_store_dir` refuses that shape
|
|
152
|
+
at creation.
|
|
153
|
+
"""
|
|
154
|
+
_reject_symlinked_store_dir(store_dir)
|
|
155
|
+
if store_dir.is_dir():
|
|
156
|
+
current = store_dir.stat().st_mode & 0o777
|
|
157
|
+
if current != _STORE_DIR_MODE:
|
|
158
|
+
store_dir.chmod(_STORE_DIR_MODE)
|
|
159
|
+
for path in database_files(store_dir / "memory.db"):
|
|
160
|
+
enforce_store_file_mode(path)
|