flowstatemcp 2.5.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.
- flowstate/__init__.py +34 -0
- flowstate/__main__.py +7 -0
- flowstate/activity.py +224 -0
- flowstate/agent_guide.py +251 -0
- flowstate/app_keys.py +141 -0
- flowstate/approvals.py +974 -0
- flowstate/approve_page.py +565 -0
- flowstate/ask.py +107 -0
- flowstate/autosession.py +480 -0
- flowstate/backup.py +201 -0
- flowstate/brief.py +420 -0
- flowstate/card_offer.py +208 -0
- flowstate/card_taps.py +165 -0
- flowstate/card_trust.py +127 -0
- flowstate/claims.py +461 -0
- flowstate/cleanup.py +619 -0
- flowstate/cli.py +728 -0
- flowstate/cli_data.py +434 -0
- flowstate/cli_more.py +302 -0
- flowstate/cli_review.py +459 -0
- flowstate/cloudflared_check.py +49 -0
- flowstate/conflict_card_ui.py +286 -0
- flowstate/conflict_cards.py +1193 -0
- flowstate/conflict_reopen.py +320 -0
- flowstate/conflicts.py +420 -0
- flowstate/connections.py +33 -0
- flowstate/database.py +2191 -0
- flowstate/dbconn.py +308 -0
- flowstate/deletes.py +860 -0
- flowstate/embeddings.py +354 -0
- flowstate/erase.py +1047 -0
- flowstate/extras.py +71 -0
- flowstate/git_capture.py +157 -0
- flowstate/guard.py +224 -0
- flowstate/handoffs.py +168 -0
- flowstate/history.py +755 -0
- flowstate/history_page.py +276 -0
- flowstate/http_app.py +204 -0
- flowstate/identity.py +387 -0
- flowstate/install_name.py +140 -0
- flowstate/ledger.py +272 -0
- flowstate/legacy_args.py +65 -0
- flowstate/links.py +609 -0
- flowstate/markdown_io.py +242 -0
- flowstate/meaning.py +398 -0
- flowstate/memory_import.py +92 -0
- flowstate/migrations/001_auto_sessions.sql +16 -0
- flowstate/migrations/002_review_queue.sql +33 -0
- flowstate/migrations/003_links.sql +47 -0
- flowstate/migrations/004_review_mode.sql +24 -0
- flowstate/migrations/005_history.sql +21 -0
- flowstate/migrations/006_activity.sql +26 -0
- flowstate/migrations/007_meaning_index.sql +16 -0
- flowstate/migrations/008_git_capture.sql +12 -0
- flowstate/migrations/009_hard_rules.sql +13 -0
- flowstate/migrations/010_handoffs.sql +14 -0
- flowstate/migrations/011_record_meta.sql +13 -0
- flowstate/migrations/012_accountability.sql +71 -0
- flowstate/migrations/013_todo_claims.sql +36 -0
- flowstate/migrations/014_decision_conflicts.sql +41 -0
- flowstate/migrations/015_session_runs.sql +17 -0
- flowstate/migrations/016_write_receipts.sql +35 -0
- flowstate/migrations/017_run_views.sql +26 -0
- flowstate/migrations/018_run_views_upto.sql +4 -0
- flowstate/migrations/019_approvals.sql +47 -0
- flowstate/migrations/020_source_stamps.sql +10 -0
- flowstate/migrations/021_review_links.sql +11 -0
- flowstate/migrations/022_review_start_tickets.sql +10 -0
- flowstate/migrations/023_conflict_cards.sql +68 -0
- flowstate/migrations/024_rule_confirmations.sql +7 -0
- flowstate/migrations/025_confirmed_hash.sql +7 -0
- flowstate/migrations/__init__.py +149 -0
- flowstate/models.py +307 -0
- flowstate/names.py +157 -0
- flowstate/offsite.py +386 -0
- flowstate/offsite_crypto.py +272 -0
- flowstate/paths.py +265 -0
- flowstate/popup.py +238 -0
- flowstate/priority.py +66 -0
- flowstate/privatefs.py +476 -0
- flowstate/project_history.py +710 -0
- flowstate/provenance.py +696 -0
- flowstate/receipts.py +377 -0
- flowstate/relay/__init__.py +72 -0
- flowstate/relay/acme.py +271 -0
- flowstate/relay/api.py +164 -0
- flowstate/relay/cli.py +344 -0
- flowstate/relay/crypto.py +137 -0
- flowstate/relay/ct.py +118 -0
- flowstate/relay/runner.py +177 -0
- flowstate/relay/store.py +191 -0
- flowstate/relay/tunnel.py +413 -0
- flowstate/release_signers +14 -0
- flowstate/remote_auth.py +1219 -0
- flowstate/review.py +320 -0
- flowstate/review_apply.py +140 -0
- flowstate/review_card_ui.py +241 -0
- flowstate/review_cards.py +564 -0
- flowstate/review_mode.py +274 -0
- flowstate/review_page.py +1361 -0
- flowstate/review_phone.py +465 -0
- flowstate/review_tunnel.py +338 -0
- flowstate/routing.py +392 -0
- flowstate/rules.py +418 -0
- flowstate/safety.py +98 -0
- flowstate/saves.py +83 -0
- flowstate/scheduler.py +160 -0
- flowstate/schema.sql +713 -0
- flowstate/search.py +355 -0
- flowstate/secret_store.py +271 -0
- flowstate/secrets_scan.py +135 -0
- flowstate/selfupdate.py +1020 -0
- flowstate/server.py +1299 -0
- flowstate/session_contents.py +382 -0
- flowstate/session_records.py +629 -0
- flowstate/site_style.py +46 -0
- flowstate/stored.py +158 -0
- flowstate/story.py +442 -0
- flowstate/surface.py +173 -0
- flowstate/tasks.py +231 -0
- flowstate/term.py +40 -0
- flowstate/tool_labels.py +104 -0
- flowstate/tooldefs.py +383 -0
- flowstate/tools/__init__.py +118 -0
- flowstate/tools/core.py +545 -0
- flowstate/tools/files.py +416 -0
- flowstate/tools/git_ops.py +1081 -0
- flowstate/tools/intelligence.py +376 -0
- flowstate/tools/learning.py +282 -0
- flowstate/tools/problems.py +159 -0
- flowstate/tools/sessions.py +266 -0
- flowstate/tools/story.py +48 -0
- flowstate/tools/todos.py +79 -0
- flowstate/tools/utils.py +71 -0
- flowstate/tools/variables.py +345 -0
- flowstate/updates.py +482 -0
- flowstate/urls.py +7 -0
- flowstate/waiting.py +828 -0
- flowstate/who.py +302 -0
- flowstatemcp-2.5.0.dist-info/METADATA +88 -0
- flowstatemcp-2.5.0.dist-info/RECORD +143 -0
- flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
- flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Encryption for the off-site GitHub copy (2.4.4, data security audit D8).
|
|
2
|
+
|
|
3
|
+
The nightly copy is encrypted on this computer before it is pushed; GitHub only ever holds the
|
|
4
|
+
encrypted file. AES-256-GCM from the `cryptography` package (it comes with `mcp`; FlowState lists it as its
|
|
5
|
+
`offsite` extra and checks for it in tests; the relay uses it too). Without it the push is
|
|
6
|
+
refused: an unencrypted copy is never pushed.
|
|
7
|
+
|
|
8
|
+
The key: 32 random bytes made on this computer the first time, kept in the OS credential store
|
|
9
|
+
(Windows Credential Manager `flowstate:offsite-key`, macOS Keychain `flowstate-offsite-key`,
|
|
10
|
+
Linux a 0600 file `~/.config/flowstate/offsite-key`). The recovery code is the same key written
|
|
11
|
+
for people (`flowstate offsite key`): save it in a password manager. Without it, a lost or
|
|
12
|
+
replaced computer can't read the copy; that is the point.
|
|
13
|
+
|
|
14
|
+
File format (`.fsenc`): MAGIC, 8-byte key id (start of SHA-256 of the key), 8-byte random nonce
|
|
15
|
+
prefix, 4-byte chunk size, and (format 2, MAGIC2) the 8-byte time the copy was made (Unix
|
|
16
|
+
seconds); then 1 MiB chunks, each sealed with nonce = prefix + 4-byte counter and the header +
|
|
17
|
+
counter + a last-chunk flag as associated data, so chunks can't be reordered, dropped or cut off
|
|
18
|
+
without the check failing, and the time in the header can't be changed. A file with no chunk at
|
|
19
|
+
all (cut down to its header) is refused. Format-1 files (no time) are still read.
|
|
20
|
+
|
|
21
|
+
The key is made only when the store says definitely "there is none"; a store that can't be
|
|
22
|
+
read stops the push, and an existing key is never written over (re-audit N-F6).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import hashlib
|
|
27
|
+
import os
|
|
28
|
+
import secrets
|
|
29
|
+
import struct
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
MAGIC = b"FSENC1\0"
|
|
34
|
+
MAGIC2 = b"FSENC2\0" # + the time the copy was made, sealed with the chunks
|
|
35
|
+
CHUNK = 1 << 20
|
|
36
|
+
_HEADER = len(MAGIC) + 8 + 8 + 4
|
|
37
|
+
_HEADER2 = _HEADER + 8
|
|
38
|
+
SUFFIX = ".fsenc"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CryptoError(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _aes(key: bytes):
|
|
46
|
+
try:
|
|
47
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
48
|
+
except ImportError:
|
|
49
|
+
raise CryptoError("encrypting the off-site copy needs the `cryptography` package "
|
|
50
|
+
"(one of FlowState's own packages). Nothing was "
|
|
51
|
+
"pushed; reinstall FlowState's packages and try again.") from None
|
|
52
|
+
return AESGCM(key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def key_id(key: bytes) -> str:
|
|
56
|
+
return hashlib.sha256(key).hexdigest()[:16]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------- the key
|
|
60
|
+
def _store():
|
|
61
|
+
from . import secret_store as SS
|
|
62
|
+
if os.environ.get("FLOWSTATE_TOKEN_STORE_FILE"): # tests and sandboxes: a private file
|
|
63
|
+
return SS.FileStore(Path(os.environ["FLOWSTATE_TOKEN_STORE_FILE"]).expanduser()
|
|
64
|
+
.with_name("offsite-key"))
|
|
65
|
+
import platform
|
|
66
|
+
system = platform.system()
|
|
67
|
+
if system == "Windows":
|
|
68
|
+
return SS.WindowsStore(target="flowstate:offsite-key")
|
|
69
|
+
if system == "Darwin":
|
|
70
|
+
return SS.MacStore(service="flowstate-offsite-key")
|
|
71
|
+
from . import paths
|
|
72
|
+
return SS.FileStore(paths.config_dir() / "offsite-key")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_DAMAGED = ("the off-site key stored on this computer is damaged, so FlowState can't use it. "
|
|
76
|
+
"It was not replaced (that would lose your earlier copies); nothing was {doing}. If "
|
|
77
|
+
"you saved its recovery code, you can still restore those copies with it.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _empty(store, doing: str = "pushed") -> str:
|
|
81
|
+
where = {"macOS Keychain": "the Keychain item flowstate-offsite-key (Keychain Access)",
|
|
82
|
+
"Windows Credential Manager": "the Credential Manager item flowstate:offsite-key"}
|
|
83
|
+
how = where.get(getattr(store, "name", ""), f"the file {getattr(store, 'path', 'offsite-key')}")
|
|
84
|
+
return (f"the off-site key on this computer is empty: {how} exists but holds no key, so "
|
|
85
|
+
f"FlowState can't use it. It was not replaced; nothing was {doing}. If you never "
|
|
86
|
+
f"pushed an encrypted off-site copy, delete that empty item and push again (a new "
|
|
87
|
+
f"key is made then). If you did, keep it and restore those copies with the "
|
|
88
|
+
f"recovery code you saved (task 56 Low).")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _decode(text: str, doing: str = "pushed") -> bytes:
|
|
92
|
+
try:
|
|
93
|
+
t = text.strip()
|
|
94
|
+
raw = base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
raise CryptoError(_DAMAGED.format(doing=doing)) from None
|
|
97
|
+
if len(raw) != 32:
|
|
98
|
+
raise CryptoError(_DAMAGED.format(doing=doing))
|
|
99
|
+
return raw
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_key(store=None, doing: str = "pushed") -> Optional[bytes]:
|
|
103
|
+
"""The key, or None only when the store says there is none. A store that can't be read
|
|
104
|
+
raises CryptoError (never "no key"). `doing`: what didn't happen, for its message
|
|
105
|
+
("pushed"; "restored" for a restore, "set up" for offsite setup: task 56 Info)."""
|
|
106
|
+
store = store or _store()
|
|
107
|
+
try:
|
|
108
|
+
v = store.get()
|
|
109
|
+
except Exception as e: # noqa: BLE001 - locked Keychain, denied prompt, unreadable file
|
|
110
|
+
raise CryptoError(
|
|
111
|
+
f"couldn't read the off-site key from {getattr(store, 'name', 'the credential store')} "
|
|
112
|
+
f"({e if str(e) else type(e).__name__}). Nothing was {doing} and no new key was "
|
|
113
|
+
f"made; unlock it (or allow FlowState) and try again.") from None
|
|
114
|
+
if v is None:
|
|
115
|
+
return None
|
|
116
|
+
if not str(v).strip(): # the item is there but empty: not "no key", never replaced
|
|
117
|
+
raise CryptoError(_empty(store, doing))
|
|
118
|
+
return _decode(v, doing)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_or_create_key(store=None) -> tuple:
|
|
122
|
+
"""(key, made_now). A new key only after a definite "not found", and only with an
|
|
123
|
+
add-only write: an existing item is never overwritten."""
|
|
124
|
+
store = store or _store()
|
|
125
|
+
key = get_key(store)
|
|
126
|
+
if key is not None:
|
|
127
|
+
return key, False
|
|
128
|
+
# one first-time key at a time on this computer: Credential Manager has no add-only write,
|
|
129
|
+
# so two pushes at the same moment could each make one, and the first copy's key be lost
|
|
130
|
+
from . import paths
|
|
131
|
+
from .privatefs import private_dir
|
|
132
|
+
private_dir(paths.config_dir())
|
|
133
|
+
try:
|
|
134
|
+
with paths._lock(paths.config_dir() / "offsite-key"):
|
|
135
|
+
return _create_key(store)
|
|
136
|
+
except TimeoutError:
|
|
137
|
+
raise CryptoError("another FlowState is making the off-site key right now; nothing was "
|
|
138
|
+
"pushed. Try again in a moment.") from None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _create_key(store) -> tuple:
|
|
142
|
+
from . import secret_store as SS
|
|
143
|
+
key = get_key(store) # made by another FlowState while this one waited
|
|
144
|
+
if key is not None:
|
|
145
|
+
return key, False
|
|
146
|
+
key = secrets.token_bytes(32)
|
|
147
|
+
text = base64.urlsafe_b64encode(key).decode().rstrip("=")
|
|
148
|
+
try:
|
|
149
|
+
if hasattr(store, "create"):
|
|
150
|
+
store.create(text)
|
|
151
|
+
else: # a store without an add-only write: check right before writing
|
|
152
|
+
if store.get() is not None:
|
|
153
|
+
raise SS.AlreadyStored("an off-site key appeared meanwhile; it was not replaced")
|
|
154
|
+
store.set(text)
|
|
155
|
+
except SS.AlreadyStored:
|
|
156
|
+
raise CryptoError("an off-site key was already stored (made by another FlowState at "
|
|
157
|
+
"the same moment?); it was not replaced. Nothing was pushed; try "
|
|
158
|
+
"again.") from None
|
|
159
|
+
except CryptoError:
|
|
160
|
+
raise
|
|
161
|
+
except Exception as e: # noqa: BLE001
|
|
162
|
+
raise CryptoError(f"the credential store refused the new off-site key "
|
|
163
|
+
f"({type(e).__name__}); nothing was pushed") from None
|
|
164
|
+
if get_key(store) != key:
|
|
165
|
+
raise CryptoError("the credential store didn't keep the new off-site key; nothing was "
|
|
166
|
+
"pushed")
|
|
167
|
+
return key, True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def recovery_code(key: bytes) -> str:
|
|
171
|
+
"""The key for people: 52 letters and digits in groups of four."""
|
|
172
|
+
b32 = base64.b32encode(key).decode().rstrip("=")
|
|
173
|
+
return "-".join(b32[i:i + 4] for i in range(0, len(b32), 4))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def key_from_code(code: str) -> bytes:
|
|
177
|
+
s = "".join(ch for ch in (code or "").upper() if ch.isalnum())
|
|
178
|
+
try:
|
|
179
|
+
key = base64.b32decode(s + "=" * (-len(s) % 8))
|
|
180
|
+
except ValueError:
|
|
181
|
+
raise CryptoError("that recovery code isn't valid (letters A-Z and digits 2-7)") from None
|
|
182
|
+
if len(key) != 32:
|
|
183
|
+
raise CryptoError("that recovery code is the wrong length")
|
|
184
|
+
return key
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------- files
|
|
188
|
+
def _aad(header: bytes, n: int, last: bool) -> bytes:
|
|
189
|
+
return header + struct.pack(">I?", n, last)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def encrypt_file(src: Path, dst: Path, key: bytes, created: Optional[int] = None) -> None:
|
|
193
|
+
"""`created`: when the copy was made (Unix seconds; default now), sealed in the header."""
|
|
194
|
+
import time
|
|
195
|
+
aes = _aes(key)
|
|
196
|
+
prefix = secrets.token_bytes(8)
|
|
197
|
+
when = int(time.time()) if created is None else int(created)
|
|
198
|
+
header = (MAGIC2 + bytes.fromhex(key_id(key)) + prefix + struct.pack(">I", CHUNK)
|
|
199
|
+
+ struct.pack(">Q", when))
|
|
200
|
+
from .privatefs import private_file
|
|
201
|
+
private_file(dst)
|
|
202
|
+
with open(src, "rb") as f, open(dst, "wb") as out:
|
|
203
|
+
out.write(header)
|
|
204
|
+
n, chunk = 0, f.read(CHUNK)
|
|
205
|
+
while True:
|
|
206
|
+
nxt = f.read(CHUNK)
|
|
207
|
+
last = not nxt
|
|
208
|
+
out.write(aes.encrypt(prefix + struct.pack(">I", n), chunk, _aad(header, n, last)))
|
|
209
|
+
if last:
|
|
210
|
+
break
|
|
211
|
+
n, chunk = n + 1, nxt
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def is_encrypted(path: Path) -> bool:
|
|
215
|
+
try:
|
|
216
|
+
with open(path, "rb") as f:
|
|
217
|
+
return f.read(len(MAGIC)) in (MAGIC, MAGIC2)
|
|
218
|
+
except OSError:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def file_key_id(path: Path) -> str:
|
|
223
|
+
with open(path, "rb") as f:
|
|
224
|
+
head = f.read(_HEADER)
|
|
225
|
+
return head[len(MAGIC):len(MAGIC) + 8].hex()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def decrypt_file(src: Path, dst: Path, key: bytes) -> dict:
|
|
229
|
+
"""Decrypt into `dst` (private). Returns {"created": Unix seconds the copy was made, from
|
|
230
|
+
the sealed header, or None for a format-1 file}; only trustworthy because it returned."""
|
|
231
|
+
from cryptography.exceptions import InvalidTag
|
|
232
|
+
aes = _aes(key)
|
|
233
|
+
with open(src, "rb") as f:
|
|
234
|
+
header = f.read(_HEADER)
|
|
235
|
+
magic = header[:len(MAGIC)]
|
|
236
|
+
if len(header) != _HEADER or magic not in (MAGIC, MAGIC2):
|
|
237
|
+
raise CryptoError("not a FlowState encrypted backup")
|
|
238
|
+
created = None
|
|
239
|
+
if magic == MAGIC2:
|
|
240
|
+
extra = f.read(8)
|
|
241
|
+
if len(extra) != 8:
|
|
242
|
+
raise CryptoError("this encrypted copy is cut short or damaged (its header is "
|
|
243
|
+
"incomplete); nothing was restored")
|
|
244
|
+
header += extra
|
|
245
|
+
created = struct.unpack(">Q", extra)[0]
|
|
246
|
+
if header[len(MAGIC):len(MAGIC) + 8].hex() != key_id(key):
|
|
247
|
+
raise CryptoError("this backup was encrypted with a different key (another "
|
|
248
|
+
"computer's, or one from before a key reset): use its recovery code")
|
|
249
|
+
prefix = header[len(MAGIC) + 8:len(MAGIC) + 16]
|
|
250
|
+
size = struct.unpack(">I", header[len(MAGIC) + 16:len(MAGIC) + 20])[0] + 16
|
|
251
|
+
from .privatefs import private_file
|
|
252
|
+
dst.unlink(missing_ok=True)
|
|
253
|
+
private_file(dst)
|
|
254
|
+
try:
|
|
255
|
+
with open(dst, "wb") as out:
|
|
256
|
+
n, chunk = 0, f.read(size)
|
|
257
|
+
if not chunk: # cut down to its header: not an empty database
|
|
258
|
+
raise CryptoError("this encrypted copy is cut short or damaged (it has no "
|
|
259
|
+
"data after its header); nothing was restored")
|
|
260
|
+
while chunk:
|
|
261
|
+
nxt = f.read(size)
|
|
262
|
+
try:
|
|
263
|
+
out.write(aes.decrypt(prefix + struct.pack(">I", n), chunk,
|
|
264
|
+
_aad(header, n, not nxt)))
|
|
265
|
+
except InvalidTag:
|
|
266
|
+
raise CryptoError("the encrypted backup is damaged or was changed "
|
|
267
|
+
"(its check failed); nothing was restored") from None
|
|
268
|
+
n, chunk = n + 1, nxt
|
|
269
|
+
except Exception:
|
|
270
|
+
dst.unlink(missing_ok=True)
|
|
271
|
+
raise
|
|
272
|
+
return {"created": created}
|
flowstate/paths.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Per-OS folders FlowState uses besides the database itself. Standard library only.
|
|
2
|
+
|
|
3
|
+
backups: FLOWSTATE_BACKUP_DIR, else Windows %LOCALAPPDATA%\\flowstate\\backups,
|
|
4
|
+
macOS ~/Library/Application Support/flowstate/backups,
|
|
5
|
+
Linux ~/.local/share/flowstate/backups
|
|
6
|
+
config: FLOWSTATE_CONFIG_DIR, else Windows %APPDATA%\\flowstate,
|
|
7
|
+
macOS ~/Library/Application Support/flowstate, Linux ~/.config/flowstate
|
|
8
|
+
|
|
9
|
+
None of these is inside the (possibly git-synced) data folder unless the user says so.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _system() -> str:
|
|
21
|
+
return platform.system()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def data_root(home: Optional[Path] = None, system: Optional[str] = None) -> Path:
|
|
25
|
+
system, h = system or _system(), Path(home) if home else Path.home()
|
|
26
|
+
if system == "Windows":
|
|
27
|
+
base = Path(os.environ["LOCALAPPDATA"]) if (not home and os.environ.get("LOCALAPPDATA")) \
|
|
28
|
+
else h / "AppData" / "Local"
|
|
29
|
+
return base / "flowstate"
|
|
30
|
+
if system == "Darwin":
|
|
31
|
+
return h / "Library" / "Application Support" / "flowstate"
|
|
32
|
+
return h / ".local" / "share" / "flowstate"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def backup_dir(home: Optional[Path] = None, system: Optional[str] = None) -> Path:
|
|
36
|
+
if not home and os.environ.get("FLOWSTATE_BACKUP_DIR"):
|
|
37
|
+
return Path(os.environ["FLOWSTATE_BACKUP_DIR"]).expanduser()
|
|
38
|
+
return data_root(home, system) / "backups"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def config_dir(home: Optional[Path] = None, system: Optional[str] = None) -> Path:
|
|
42
|
+
if not home and os.environ.get("FLOWSTATE_CONFIG_DIR"):
|
|
43
|
+
return Path(os.environ["FLOWSTATE_CONFIG_DIR"]).expanduser()
|
|
44
|
+
system, h = system or _system(), Path(home) if home else Path.home()
|
|
45
|
+
if system == "Windows":
|
|
46
|
+
base = Path(os.environ["APPDATA"]) if (not home and os.environ.get("APPDATA")) \
|
|
47
|
+
else h / "AppData" / "Roaming"
|
|
48
|
+
return base / "flowstate"
|
|
49
|
+
if system == "Darwin":
|
|
50
|
+
return h / "Library" / "Application Support" / "flowstate"
|
|
51
|
+
return h / ".config" / "flowstate"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------- settings file
|
|
55
|
+
def config_file() -> Path:
|
|
56
|
+
return config_dir() / "config.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConfigUnreadable(Exception):
|
|
60
|
+
"""config.json exists but isn't a JSON object FlowState can read (a hand-edit typo, or a
|
|
61
|
+
file another program is half-way through writing)."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, path: Path):
|
|
64
|
+
super().__init__(f"FlowState's settings file {path} can't be read (it isn't valid JSON). "
|
|
65
|
+
f"Nothing was changed. Fix or delete that file, then try again.")
|
|
66
|
+
self.path = path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_config(p: Path) -> dict:
|
|
70
|
+
"""The settings, {} when there is no file; ConfigUnreadable when there is one that doesn't
|
|
71
|
+
parse. On Windows a reader can be refused for a moment while a writer replaces the file,
|
|
72
|
+
so a refused read is tried again briefly first."""
|
|
73
|
+
import time
|
|
74
|
+
deadline = time.monotonic() + 1.0
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
text = p.read_text(encoding="utf-8")
|
|
78
|
+
break
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
return {}
|
|
81
|
+
except PermissionError:
|
|
82
|
+
if time.monotonic() > deadline:
|
|
83
|
+
raise ConfigUnreadable(p) from None
|
|
84
|
+
time.sleep(0.02)
|
|
85
|
+
except OSError:
|
|
86
|
+
raise ConfigUnreadable(p) from None
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(text)
|
|
89
|
+
except ValueError:
|
|
90
|
+
raise ConfigUnreadable(p) from None
|
|
91
|
+
if not isinstance(data, dict):
|
|
92
|
+
raise ConfigUnreadable(p)
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_config(strict: bool = False) -> dict:
|
|
97
|
+
"""FlowState's own settings (no secrets ever go in here). A file that can't be read counts
|
|
98
|
+
as no settings, unless `strict` (then ConfigUnreadable): a setting that must fail safe
|
|
99
|
+
(updates.self_update_allowed, the name sent to the relay) and every writer use strict."""
|
|
100
|
+
try:
|
|
101
|
+
return _read_config(config_file())
|
|
102
|
+
except ConfigUnreadable:
|
|
103
|
+
if strict:
|
|
104
|
+
raise
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def replace_file(tmp: Path, p: Path, wait: float = 2.0) -> None:
|
|
109
|
+
"""os.replace(tmp, p), the last step of an atomic write. On Windows the replace is refused
|
|
110
|
+
(access denied) while another process has `p` open, e.g. `flowstate relay status` reading
|
|
111
|
+
the file the relay agent rewrites; readers hold it for moments, so try again briefly."""
|
|
112
|
+
import time
|
|
113
|
+
deadline = time.monotonic() + wait
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
os.replace(tmp, p)
|
|
117
|
+
return
|
|
118
|
+
except PermissionError:
|
|
119
|
+
if os.name != "nt" or time.monotonic() > deadline:
|
|
120
|
+
raise
|
|
121
|
+
time.sleep(0.02)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_config(data: dict) -> Path:
|
|
125
|
+
"""Write the whole settings file atomically (a temp file of its own, then replace). Use
|
|
126
|
+
update_config to change one setting: it keeps the others."""
|
|
127
|
+
import secrets
|
|
128
|
+
from .privatefs import private_dir, write_private
|
|
129
|
+
p = config_file()
|
|
130
|
+
private_dir(p.parent)
|
|
131
|
+
tmp = p.with_name(f"{p.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
|
132
|
+
try:
|
|
133
|
+
write_private(tmp, json.dumps(data, indent=2) + "\n") # owner-only (audit D9)
|
|
134
|
+
replace_file(tmp, p)
|
|
135
|
+
finally:
|
|
136
|
+
if tmp.exists():
|
|
137
|
+
tmp.unlink()
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
LOCK_WAIT = 5.0 # seconds a settings writer waits for another one
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _try_lock(fd: int) -> bool:
|
|
145
|
+
"""An OS lock on the open lock file, without waiting: True if we have it, False if another
|
|
146
|
+
process (or thread) holds it. The OS drops it when the holder exits, so a writer that
|
|
147
|
+
crashed never blocks the next one. A filesystem without locks counts as "have it"."""
|
|
148
|
+
import errno
|
|
149
|
+
try:
|
|
150
|
+
if os.name == "nt":
|
|
151
|
+
import msvcrt
|
|
152
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
153
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
154
|
+
else:
|
|
155
|
+
import fcntl
|
|
156
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
157
|
+
return True
|
|
158
|
+
except OSError as e:
|
|
159
|
+
if e.errno in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK, errno.EDEADLK):
|
|
160
|
+
return False
|
|
161
|
+
return True # e.g. ENOLCK / ENOTSUP on an odd filesystem: go ahead as before 2.4.4
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _unlock(fd: int) -> None:
|
|
165
|
+
try:
|
|
166
|
+
if os.name == "nt":
|
|
167
|
+
import msvcrt
|
|
168
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
169
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
170
|
+
else:
|
|
171
|
+
import fcntl
|
|
172
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_thread_lock = threading.Lock() # threads of one process take turns too (and wait less)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _quiet_unlink(f: Path) -> None:
|
|
181
|
+
try:
|
|
182
|
+
f.unlink()
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _lock(p: Path):
|
|
188
|
+
"""An OS file lock (flock / msvcrt) on a lock file next to config.json, held while one
|
|
189
|
+
writer reads, changes and writes the settings, so two writers at once can't lose each
|
|
190
|
+
other's change. Nothing to go stale: the OS releases it when its holder exits (audit
|
|
191
|
+
N-F23: a lock left by a crashed writer no longer blocks for 30 s, and two waiting
|
|
192
|
+
writers can't both "break" one). After locking, the file must still be the one at the
|
|
193
|
+
path (the last holder removes it when done), else it starts over."""
|
|
194
|
+
import contextlib
|
|
195
|
+
import time
|
|
196
|
+
|
|
197
|
+
@contextlib.contextmanager
|
|
198
|
+
def held():
|
|
199
|
+
lock = p.with_name(p.name + ".lock")
|
|
200
|
+
deadline = time.monotonic() + LOCK_WAIT
|
|
201
|
+
|
|
202
|
+
def busy():
|
|
203
|
+
raise TimeoutError(f"Another FlowState command is changing {p}; try again "
|
|
204
|
+
f"in a moment.") from None
|
|
205
|
+
if not _thread_lock.acquire(timeout=LOCK_WAIT):
|
|
206
|
+
busy()
|
|
207
|
+
try:
|
|
208
|
+
while True:
|
|
209
|
+
# Windows refuses the open (and the stat) while the last holder's removal of
|
|
210
|
+
# the file is still going through: a busy lock, not an error.
|
|
211
|
+
try:
|
|
212
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o600)
|
|
213
|
+
except PermissionError:
|
|
214
|
+
fd = None
|
|
215
|
+
if fd is not None:
|
|
216
|
+
if _try_lock(fd):
|
|
217
|
+
try:
|
|
218
|
+
same = os.path.samestat(os.fstat(fd), os.stat(str(lock)))
|
|
219
|
+
except (FileNotFoundError, PermissionError):
|
|
220
|
+
same = False
|
|
221
|
+
if same:
|
|
222
|
+
break
|
|
223
|
+
_unlock(fd) # its holder removed it as we opened it: take the new one
|
|
224
|
+
os.close(fd)
|
|
225
|
+
if time.monotonic() > deadline:
|
|
226
|
+
busy()
|
|
227
|
+
time.sleep(0.02)
|
|
228
|
+
try:
|
|
229
|
+
yield
|
|
230
|
+
finally:
|
|
231
|
+
# POSIX: remove it while still holding it, so whoever has it open next sees a
|
|
232
|
+
# file that is no longer at the path and starts over. Windows can't remove an
|
|
233
|
+
# open file: unlock and close first; if another writer opened it meanwhile, the
|
|
234
|
+
# removal is refused and that writer keeps the same file.
|
|
235
|
+
if os.name != "nt":
|
|
236
|
+
_quiet_unlink(lock)
|
|
237
|
+
_unlock(fd)
|
|
238
|
+
os.close(fd)
|
|
239
|
+
if os.name == "nt":
|
|
240
|
+
_quiet_unlink(lock)
|
|
241
|
+
finally:
|
|
242
|
+
_thread_lock.release()
|
|
243
|
+
return held()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def update_config(change) -> Path:
|
|
247
|
+
"""Change the settings: `change(cfg)` edits the dict in place, under the lock, on a strict
|
|
248
|
+
read (a file that exists but can't be read raises ConfigUnreadable and is left as it is,
|
|
249
|
+
so one typo can't wipe the other settings)."""
|
|
250
|
+
from .privatefs import private_dir
|
|
251
|
+
p = config_file()
|
|
252
|
+
private_dir(p.parent) # owner-only when FlowState makes it (audit D9)
|
|
253
|
+
with _lock(p):
|
|
254
|
+
cfg = _read_config(p)
|
|
255
|
+
change(cfg)
|
|
256
|
+
return save_config(cfg)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def inside_git_worktree(path: Path) -> Optional[Path]:
|
|
260
|
+
"""The git work tree containing `path`, if any (checks for a .git entry upwards)."""
|
|
261
|
+
p = Path(path).resolve()
|
|
262
|
+
for d in [p] + list(p.parents):
|
|
263
|
+
if (d / ".git").exists():
|
|
264
|
+
return d
|
|
265
|
+
return None
|