@vibelord/lanes 1.2.0 → 1.3.0
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.
- package/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +301 -45
- package/payload/lanes/container/lane +42 -1
- package/payload/lanes/container/lane-mcp +65 -6
- package/payload/lanes/container/lane_core.py +178 -0
- package/payload/lanes/docs/ARCHITECTURE.md +192 -20
- package/payload/lanes/docs/DESIGN.md +6 -3
- package/payload/lanes/docs/INSTALL.md +25 -8
- package/payload/lanes/docs/RUNBOOK.md +369 -8
- package/payload/lanes/docs/USERGUIDE.md +201 -25
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +106 -13
- package/payload/lanes/libexec/statusd.py +97 -5
- package/payload/lanes/libexec/tokens.py +31 -9
- package/payload/lanes/libexec/update.py +94 -22
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +4 -3
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
"""lanes docs store — documents an agent publishes from a lane or a person uploads, kept per login with a sha256
|
|
2
|
+
receipt (W10 docs cockpit W1, LNS-102).
|
|
3
|
+
|
|
4
|
+
Layout, all under L.STATE (directories 0700, files 0600):
|
|
5
|
+
|
|
6
|
+
docs/<stem(login)>/<ws>/<lane>/<id>/<name> the document, byte for byte as it arrived
|
|
7
|
+
docs/<stem(login)>/<ws>/<lane>/<id>/meta.json {v, id, ws, lane, kind, title, name, mime, bytes, sha256,
|
|
8
|
+
at, by, for, source}
|
|
9
|
+
docs/<stem(login)>/<ws>/<lane>/<id>/receipt.json {v, id, sha256, bytes, url, at}
|
|
10
|
+
docs/counts.json {<lane id>: {n, at}} — every login's documents per lane
|
|
11
|
+
docs/.lock the store lock (flock) every change holds
|
|
12
|
+
docs.log append-only: `<at> <login> <event> <id> <lane> <bytes> <sha256>
|
|
13
|
+
<outcome>` — never content, a title or a name; at
|
|
14
|
+
LOG_ROTATE_BYTES (8 MiB) it becomes docs.log.1, one generation
|
|
15
|
+
|
|
16
|
+
* `stem(login)` is users.stem (the login percent-quoted with nothing safe, as the profile store names its files),
|
|
17
|
+
refused here when it is no directory name of its own: `.`, `..` or any other stem that starts with a dot (a
|
|
18
|
+
login may be `..`, and quote() keeps dots), or `counts.json`. `ws` is users.check_workspace's id and `lane`
|
|
19
|
+
users.check_lane's lane id (at most 255 characters here: one directory name). Every component is validated
|
|
20
|
+
before anything is written, and an existing directory on the way that is a symbolic link is refused.
|
|
21
|
+
* An id is `new_id()`: base36 epoch seconds (padded to 6) and 6 random base32 characters, ID_RE; ids are minted
|
|
22
|
+
with an exclusive mkdir, so an id dir is never reused. `name` is clean_name(original): the basename (after the
|
|
23
|
+
last `/` or `\\`), only `[A-Za-z0-9._-]`, dot runs collapsed, a leading dot behind `file` (`.bashrc` ->
|
|
24
|
+
`file.bashrc`), no trailing dot, at most MAX_NAME characters (the extension kept), never empty (`file`); one
|
|
25
|
+
already used by a document of the same login, workspace and lane, or `meta.json` / `receipt.json`, gains
|
|
26
|
+
`-1`, `-2` … before its suffix, exactly as bin/lanes cmd_upload names a file in `uploads/`.
|
|
27
|
+
* `intake()` streams the reader into a temporary file in a new id dir, at most `limit` bytes (one byte more is a
|
|
28
|
+
TooLarge refusal; it never reads past it), hashing as it goes on the host — a claimed hash is never taken. Under
|
|
29
|
+
the store lock (LOCK_S, else Busy) it names the file, renames it into place, writes receipt.json and then
|
|
30
|
+
meta.json (the document exists for readers once meta.json does) and the lane's entry of counts.json. Any
|
|
31
|
+
failure removes the id dir: nothing is left behind. `mime` is Python's built-in table for the name (never the
|
|
32
|
+
host's mime.types, so it does not differ between machines), `application/octet-stream` when it has none or the
|
|
33
|
+
name says the bytes are encoded (`.tar.gz` is gzip, not tar). `at` is UTC ISO seconds; `for` the logins the
|
|
34
|
+
document is for, unique and sorted (empty: every member); `by` who put it there, or null.
|
|
35
|
+
* Visibility (load, list_for, remove): a document is visible to the login whose store holds it, to every login when
|
|
36
|
+
`for` is empty, else to the logins `for` names (compared casefolded, as enrold compares member logins). The
|
|
37
|
+
caller has already established that `login` is a member (docsd's Serve-header gate); the store never reads
|
|
38
|
+
members/. remove() is for the store's login or a login `for` names — a document for everyone is removable only
|
|
39
|
+
by its owner (Forbidden); one the login cannot see is NotFound, like one that does not exist.
|
|
40
|
+
* Readers never follow a symbolic link and skip what does not validate: an id dir without a valid meta.json (an
|
|
41
|
+
intake in flight, a crash), a meta whose id/ws/lane do not match its path, a document file that is not a regular
|
|
42
|
+
file of the recorded size. counts.json is derived: recount() rebuilds it from the documents on disk.
|
|
43
|
+
* Callers: docsd (W3, libexec/docsd.py: list_for and load for its pages, intake for POST /up, audit for its reads
|
|
44
|
+
and refusals — it narrows the visibility rule above for a document whose `for` is empty to a member whose record
|
|
45
|
+
grants the workspace); docsd's publish pick-up (W4, docsd.Pickup: intake with `source` `publish` and `by` the lane
|
|
46
|
+
id it came from, a reader that refuses a sha256 other than the request's inside intake, so a mismatch leaves
|
|
47
|
+
nothing); both upload paths — POST /up and the SSH droplet `lanes upload` — which reach intake through
|
|
48
|
+
libexec/uploads.py (W5), landing the same bytes in the workspace's `uploads/` in one pass (`source` web | ssh);
|
|
49
|
+
statusd's lane counts (W7); `lanes docs push <id>` (W9: operator_receipt, the operator's read of one receipt).
|
|
50
|
+
Content, titles and names never reach docs.log; counts expose a per-lane number to every member.
|
|
51
|
+
* Encryption at rest (W5, LNS-106; operator decision 2026-10-08: designed in, default off). When
|
|
52
|
+
LANES_DOCS_AGE_RECIPIENT is set (age_settings: the environment when it names either age setting — lanes-docsd's
|
|
53
|
+
unit gives it its drop-in's —, else lanes-docsd.service's drop-in files themselves, which is how `lanes upload`
|
|
54
|
+
over SSH sees the same switch), intake pipes the bytes through `age -r <recipient>` as it hashes them and stores
|
|
55
|
+
`<name>.age` instead of `<name>`; meta.json then ends with `enc: {alg: "age", file: "<name>.age", bytes: <stored
|
|
56
|
+
size>, recipient}` and `name`, `mime`, `bytes` and `sha256` stay the PLAINTEXT's, computed before encryption, so
|
|
57
|
+
the receipt attests what arrived and what a decrypting reader gets back (age output is randomised: a hash of it
|
|
58
|
+
would match nothing anyone holds). decrypt() runs `age -d -i <identity>` (LANES_DOCS_AGE_IDENTITY) and checks
|
|
59
|
+
the plaintext against both. Readers of an older release find no `<name>` and skip the document. No `age` binary
|
|
60
|
+
with the recipient set: intake refuses (EncryptError, outcome `encrypt-failed`); host-install.sh refuses the
|
|
61
|
+
setting first (`python3 docs_store.py check-age`).
|
|
62
|
+
"""
|
|
63
|
+
import fcntl
|
|
64
|
+
import hashlib
|
|
65
|
+
import json
|
|
66
|
+
import mimetypes
|
|
67
|
+
import os
|
|
68
|
+
import re
|
|
69
|
+
import secrets
|
|
70
|
+
import shlex
|
|
71
|
+
import shutil
|
|
72
|
+
import stat
|
|
73
|
+
import subprocess
|
|
74
|
+
import sys
|
|
75
|
+
import tempfile
|
|
76
|
+
import time
|
|
77
|
+
from contextlib import contextmanager
|
|
78
|
+
from pathlib import Path, PurePosixPath
|
|
79
|
+
|
|
80
|
+
import lanes_lib as L
|
|
81
|
+
import members
|
|
82
|
+
import users
|
|
83
|
+
|
|
84
|
+
VERSION = 1
|
|
85
|
+
ID_RE = re.compile(r'^[a-z0-9]{10,20}$')
|
|
86
|
+
BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
87
|
+
BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
|
|
88
|
+
ID_TIME_CHARS = 6 # base36 epoch seconds, zero-padded (6 characters until 2038-12-24)
|
|
89
|
+
ID_RANDOM_CHARS = 6 # 30 random bits per second
|
|
90
|
+
MAX_NAME = 128 # a cleaned name; a collision suffix may add up to NAME_SLACK more
|
|
91
|
+
NAME_SLACK = 8
|
|
92
|
+
MAX_EXTENSION = 16 # an extension clean_name keeps when it shortens a name
|
|
93
|
+
MAX_TITLE = 200
|
|
94
|
+
MAX_FOR = 64
|
|
95
|
+
MAX_COMPONENT = 255 # NAME_MAX: one directory name
|
|
96
|
+
MAX_META = 64 * 1024
|
|
97
|
+
MAX_COUNTS = 4 * 1024 * 1024
|
|
98
|
+
CHUNK = 64 * 1024
|
|
99
|
+
LOCK_S = 2.0 # seconds a change waits for docs/.lock before it is refused as Busy
|
|
100
|
+
LOG_ROTATE_BYTES = 8 * 1024 * 1024 # docs.log at this size becomes docs.log.1 (one generation) before the next line
|
|
101
|
+
META, RECEIPT, COUNTS, LOCK = 'meta.json', 'receipt.json', 'counts.json', '.lock'
|
|
102
|
+
RESERVED_NAMES = frozenset((META, RECEIPT))
|
|
103
|
+
RESERVED_STEMS = frozenset((COUNTS,))
|
|
104
|
+
KINDS = ('upload', 'md', 'html', 'svg', 'file')
|
|
105
|
+
URL = '/lanes-docs/d/{}'
|
|
106
|
+
_NAME_DROP = re.compile(r'[^A-Za-z0-9._-]')
|
|
107
|
+
_NAME = re.compile(r'[A-Za-z0-9_-][A-Za-z0-9._-]*\Z')
|
|
108
|
+
_SOURCE = re.compile(r'[a-z][a-z0-9-]{0,31}\Z')
|
|
109
|
+
_SHA256 = re.compile(r'[0-9a-f]{64}\Z')
|
|
110
|
+
_AT = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\Z', re.ASCII)
|
|
111
|
+
_LOG_UNSAFE = re.compile(r'[^A-Za-z0-9_.@:+=/-]') # enrold's enrol.log field alphabet
|
|
112
|
+
_MIME = mimetypes.MimeTypes() # the built-in table only: no host mime.types
|
|
113
|
+
# Deterministic across interpreters (container/lane_core.py carries the same table for the publish request).
|
|
114
|
+
MIME_BY_SUFFIX = {'.md': 'text/markdown', '.markdown': 'text/markdown', '.html': 'text/html', '.htm': 'text/html',
|
|
115
|
+
'.svg': 'image/svg+xml', '.txt': 'text/plain', '.json': 'application/json', '.csv': 'text/csv',
|
|
116
|
+
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
117
|
+
'.gif': 'image/gif', '.webp': 'image/webp'}
|
|
118
|
+
AGE_RECIPIENT, AGE_IDENTITY = 'LANES_DOCS_AGE_RECIPIENT', 'LANES_DOCS_AGE_IDENTITY'
|
|
119
|
+
AGE_SUFFIX = '.age'
|
|
120
|
+
AGE_S = 120 # seconds `age` may take for one document
|
|
121
|
+
MAX_RECIPIENT = 4096 # an age1… key is 62 characters, an ssh-rsa line a few hundred
|
|
122
|
+
MAX_UNIT_FILE = 64 * 1024 # a drop-in file larger than this is not read
|
|
123
|
+
DOCSD_UNIT = 'lanes-docsd.service'
|
|
124
|
+
_RECIPIENT = re.compile(r'[\x21-\x2c\x2e-\x7e][\x20-\x7e]*\Z') # printable ASCII; never a leading blank or `-`
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DocsError(L.LanesError):
|
|
128
|
+
"""A refusal of the docs store; the message never echoes the refused value."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class EncryptError(DocsError):
|
|
132
|
+
"""`age` is configured but absent, failed, or gave back other bytes than the meta records."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TooLarge(DocsError):
|
|
136
|
+
def __init__(self, limit, received):
|
|
137
|
+
self.limit, self.received = limit, received
|
|
138
|
+
super().__init__(f'the document is larger than {limit} bytes')
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Busy(DocsError):
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class NotFound(DocsError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Forbidden(DocsError):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _now():
|
|
154
|
+
return time.time()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def stamp(t):
|
|
158
|
+
"""UTC ISO seconds, the `at` of every store document and docs.log line."""
|
|
159
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def root():
|
|
163
|
+
return L.STATE / 'docs'
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def counts_path():
|
|
167
|
+
return root() / COUNTS
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def log_path():
|
|
171
|
+
return L.STATE / 'docs.log'
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def rotated_log_path():
|
|
175
|
+
return L.STATE / 'docs.log.1'
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------------------------------------------ identities
|
|
179
|
+
|
|
180
|
+
def new_id(now=None):
|
|
181
|
+
"""A document id: base36 epoch seconds (zero-padded to ID_TIME_CHARS) + ID_RANDOM_CHARS random base32."""
|
|
182
|
+
seconds, digits = int(_now() if now is None else now), ''
|
|
183
|
+
while True:
|
|
184
|
+
seconds, rest = divmod(seconds, 36)
|
|
185
|
+
digits = BASE36[rest] + digits
|
|
186
|
+
if not seconds:
|
|
187
|
+
break
|
|
188
|
+
return digits.rjust(ID_TIME_CHARS, '0') + ''.join(secrets.choice(BASE32) for _ in range(ID_RANDOM_CHARS))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_id(value):
|
|
192
|
+
if not isinstance(value, str) or not ID_RE.fullmatch(value):
|
|
193
|
+
raise DocsError('a document id is 10 to 20 lowercase letters and digits')
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def login_stem(login):
|
|
198
|
+
"""users.stem(login), refused when it is no directory name of its own (see the module docstring)."""
|
|
199
|
+
stem = users.stem(login)
|
|
200
|
+
if stem.startswith('.') or stem in RESERVED_STEMS:
|
|
201
|
+
raise DocsError('this login cannot name a docs store directory')
|
|
202
|
+
return stem
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_workspace(value):
|
|
206
|
+
return users.check_workspace(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_lane(value):
|
|
210
|
+
users.check_lane(value)
|
|
211
|
+
if len(value) > MAX_COMPONENT:
|
|
212
|
+
raise DocsError(f'a lane id longer than {MAX_COMPONENT} characters cannot name a docs directory')
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _tidy(text):
|
|
217
|
+
text = re.sub(r'\.{2,}', '.', text)
|
|
218
|
+
if text.startswith('.'):
|
|
219
|
+
text = 'file' + text
|
|
220
|
+
return text.rstrip('.')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def clean_name(original):
|
|
224
|
+
"""The stored name for an original file name (see the module docstring): never empty, never a path."""
|
|
225
|
+
base = re.split(r'[/\\]', original if isinstance(original, str) else '')[-1]
|
|
226
|
+
name = _tidy(_NAME_DROP.sub('', base))
|
|
227
|
+
if len(name) > MAX_NAME:
|
|
228
|
+
suffix = PurePosixPath(name).suffix
|
|
229
|
+
suffix = suffix if len(suffix) <= MAX_EXTENSION else ''
|
|
230
|
+
name = _tidy(name[:MAX_NAME - len(suffix)].rstrip('.') + suffix)
|
|
231
|
+
return name or 'file'
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_name(value):
|
|
235
|
+
"""A name a store document may have: what clean_name makes, plus a collision suffix; never a store file."""
|
|
236
|
+
if (not isinstance(value, str) or not _NAME.match(value) or '..' in value
|
|
237
|
+
or len(value) > MAX_NAME + NAME_SLACK or value in RESERVED_NAMES):
|
|
238
|
+
raise DocsError('a document name is a plain file name of letters, digits, ".", "_" and "-"')
|
|
239
|
+
return value
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _title(value):
|
|
243
|
+
"""None (also for an empty title), or one printable line of at most MAX_TITLE characters (members' rule)."""
|
|
244
|
+
if value is None or value == '':
|
|
245
|
+
return None
|
|
246
|
+
return members._text(value, 'a title', MAX_TITLE, blanks=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _for(values):
|
|
250
|
+
if values is None:
|
|
251
|
+
return []
|
|
252
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, (list, tuple)):
|
|
253
|
+
raise DocsError('for_logins is a list of logins')
|
|
254
|
+
logins = sorted({members.check_login(value, 'a login in for_logins') for value in values})
|
|
255
|
+
if len(logins) > MAX_FOR:
|
|
256
|
+
raise DocsError(f'a document is for at most {MAX_FOR} logins')
|
|
257
|
+
return logins
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _limit(value):
|
|
261
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
262
|
+
raise DocsError('limit is a non-negative whole number of bytes')
|
|
263
|
+
return value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _source(value):
|
|
267
|
+
if not isinstance(value, str) or not _SOURCE.match(value):
|
|
268
|
+
raise DocsError('source is a lowercase word of at most 32 characters')
|
|
269
|
+
return value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _kind(value):
|
|
273
|
+
if value not in KINDS:
|
|
274
|
+
raise DocsError('kind is one of ' + ', '.join(KINDS))
|
|
275
|
+
return value
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _mime(name):
|
|
279
|
+
# The document kinds the cockpit renders carry their own types: Python's built-in table gained `.md` only in
|
|
280
|
+
# 3.12, so on an older interpreter (CI's runner) `plan.md` was `application/octet-stream` while the dev box said
|
|
281
|
+
# `text/markdown`. The host table is the fallback for everything else; an encoded name (`.tar.gz`) stays opaque.
|
|
282
|
+
mime = MIME_BY_SUFFIX.get(os.path.splitext(name)[1].lower())
|
|
283
|
+
if mime:
|
|
284
|
+
return mime
|
|
285
|
+
kind, encoding = _MIME.guess_type(name)
|
|
286
|
+
return kind if kind and not encoding else 'application/octet-stream'
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------------------------------- encryption at rest
|
|
290
|
+
|
|
291
|
+
def dropin_dir(environ=None):
|
|
292
|
+
"""lanes-docsd.service's drop-in directory: $XDG_CONFIG_HOME (when absolute) or ~/.config, then
|
|
293
|
+
systemd/user/lanes-docsd.service.d — where `systemctl --user edit lanes-docsd.service` writes override.conf."""
|
|
294
|
+
environ = os.environ if environ is None else environ
|
|
295
|
+
config = environ.get('XDG_CONFIG_HOME') or ''
|
|
296
|
+
base = Path(config) if config.startswith('/') else Path(environ.get('HOME') or Path.home()) / '.config'
|
|
297
|
+
return base / 'systemd' / 'user' / f'{DOCSD_UNIT}.d'
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def unit_environment(directory=None):
|
|
301
|
+
"""The variables the `Environment=` lines of the drop-in files (`*.conf` in `directory`, default dropin_dir(), in
|
|
302
|
+
name order as systemd applies them) set in their [Service] section -> {name: value}. Words are split as a shell
|
|
303
|
+
splits them (systemd's quoting for the cases that matter here); an empty `Environment=` clears what came before, as
|
|
304
|
+
in systemd. A file that is a link, not regular or over MAX_UNIT_FILE is skipped. Never raises."""
|
|
305
|
+
found = {}
|
|
306
|
+
directory = Path(directory) if directory is not None else dropin_dir()
|
|
307
|
+
try:
|
|
308
|
+
names = sorted(entry.name for entry in os.scandir(directory) if entry.name.endswith('.conf'))
|
|
309
|
+
dir_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_CLOEXEC', 0))
|
|
310
|
+
except OSError:
|
|
311
|
+
return found
|
|
312
|
+
try:
|
|
313
|
+
for name in names:
|
|
314
|
+
raw = L.read_regular_at(dir_fd, name, MAX_UNIT_FILE)
|
|
315
|
+
if raw is None:
|
|
316
|
+
continue
|
|
317
|
+
section = None
|
|
318
|
+
for line in raw.decode('utf-8', 'replace').splitlines():
|
|
319
|
+
line = line.strip()
|
|
320
|
+
if line.startswith('[') and line.endswith(']'):
|
|
321
|
+
section = line[1:-1]
|
|
322
|
+
elif section == 'Service' and line.startswith('Environment='):
|
|
323
|
+
value = line[len('Environment='):].strip()
|
|
324
|
+
if not value:
|
|
325
|
+
found.clear()
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
words = shlex.split(value)
|
|
329
|
+
except ValueError:
|
|
330
|
+
continue
|
|
331
|
+
found.update(word.split('=', 1) for word in words if '=' in word)
|
|
332
|
+
finally:
|
|
333
|
+
os.close(dir_fd)
|
|
334
|
+
return found
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def age_settings(environ=None):
|
|
338
|
+
"""(recipient or None, identity path or None) of the `age` switch: from `environ` (default os.environ) when it names
|
|
339
|
+
LANES_DOCS_AGE_RECIPIENT or LANES_DOCS_AGE_IDENTITY (docsd's unit puts its drop-in there), else from the drop-in
|
|
340
|
+
files themselves (unit_environment: `lanes upload` over SSH runs outside the unit). An empty value is unset. Raises
|
|
341
|
+
DocsError for a recipient that is not one printable line (never echoed)."""
|
|
342
|
+
environ = os.environ if environ is None else environ
|
|
343
|
+
if AGE_RECIPIENT not in environ and AGE_IDENTITY not in environ:
|
|
344
|
+
environ = unit_environment(dropin_dir(environ))
|
|
345
|
+
recipient = environ.get(AGE_RECIPIENT) or None
|
|
346
|
+
identity = environ.get(AGE_IDENTITY) or None
|
|
347
|
+
if recipient is not None and (len(recipient) > MAX_RECIPIENT or not _RECIPIENT.match(recipient)):
|
|
348
|
+
raise DocsError('LANES_DOCS_AGE_RECIPIENT is not an age recipient (one printable line, not an option)')
|
|
349
|
+
return recipient, identity
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def age_binary():
|
|
353
|
+
return shutil.which('age')
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def check_age(environ=None):
|
|
357
|
+
"""None when the `age` switch can work here (off, or on with an `age` on PATH), else the reason it cannot."""
|
|
358
|
+
try:
|
|
359
|
+
recipient, _identity = age_settings(environ)
|
|
360
|
+
except DocsError as error:
|
|
361
|
+
return str(error)
|
|
362
|
+
if recipient is not None and age_binary() is None:
|
|
363
|
+
return (f'LANES_DOCS_AGE_RECIPIENT is set ({dropin_dir(environ)} or the environment) but there is no age '
|
|
364
|
+
'binary on PATH: install age (https://age-encryption.org) or remove the setting')
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def stored_file(meta):
|
|
369
|
+
"""(file name, size) of what lies on disk for a document: `<name>` and `bytes`, or for an encrypted one `enc.file`
|
|
370
|
+
(`<name>.age`) and `enc.bytes`."""
|
|
371
|
+
enc = meta.get('enc')
|
|
372
|
+
return (enc['file'], enc['bytes']) if enc else (meta['name'], meta['bytes'])
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _valid_enc(meta):
|
|
376
|
+
enc = meta.get('enc')
|
|
377
|
+
if enc is None:
|
|
378
|
+
return 'enc' not in meta
|
|
379
|
+
return (isinstance(enc, dict) and set(enc) == {'alg', 'file', 'bytes', 'recipient'} and enc['alg'] == 'age'
|
|
380
|
+
and enc['file'] == f'{meta["name"]}{AGE_SUFFIX}' and isinstance(enc['bytes'], int)
|
|
381
|
+
and not isinstance(enc['bytes'], bool) and enc['bytes'] >= 0 and isinstance(enc['recipient'], str))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _age_error(process, errors, what):
|
|
385
|
+
errors.seek(0)
|
|
386
|
+
said = errors.read(2048).decode('utf-8', 'replace').strip().splitlines()
|
|
387
|
+
if said: # the journal hears age; the caller only a word
|
|
388
|
+
print(f'lanes docs: age {what}: {said[-1]}', file=sys.stderr, flush=True)
|
|
389
|
+
return EncryptError(f'age could not {what} the document (exit {process.returncode})')
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def decrypt(meta, fd, identity):
|
|
393
|
+
"""The plaintext of the encrypted document `meta` whose stored file is open as `fd`, by `age -d -i <identity>` ->
|
|
394
|
+
bytes, exactly meta['bytes'] long with meta['sha256'] (else EncryptError). Holds the plaintext in memory: the
|
|
395
|
+
caller bounds meta['bytes']."""
|
|
396
|
+
age = age_binary()
|
|
397
|
+
if age is None or not identity:
|
|
398
|
+
raise EncryptError('no age binary or identity to decrypt with')
|
|
399
|
+
with tempfile.TemporaryFile() as errors:
|
|
400
|
+
process = subprocess.Popen([age, '-d', '-i', identity], stdin=fd, stdout=subprocess.PIPE, stderr=errors,
|
|
401
|
+
close_fds=True)
|
|
402
|
+
chunks, size = [], 0
|
|
403
|
+
try:
|
|
404
|
+
while size <= meta['bytes']:
|
|
405
|
+
chunk = process.stdout.read(CHUNK)
|
|
406
|
+
if not chunk:
|
|
407
|
+
break
|
|
408
|
+
chunks.append(chunk)
|
|
409
|
+
size += len(chunk)
|
|
410
|
+
finally:
|
|
411
|
+
process.stdout.close()
|
|
412
|
+
try:
|
|
413
|
+
process.wait(AGE_S)
|
|
414
|
+
except subprocess.TimeoutExpired:
|
|
415
|
+
process.kill()
|
|
416
|
+
process.wait()
|
|
417
|
+
if process.returncode != 0:
|
|
418
|
+
raise _age_error(process, errors, 'decrypt')
|
|
419
|
+
data = b''.join(chunks)
|
|
420
|
+
if len(data) != meta['bytes'] or hashlib.sha256(data).hexdigest() != meta['sha256']:
|
|
421
|
+
raise EncryptError('the decrypted document is not the one its receipt names')
|
|
422
|
+
return data
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# -------------------------------------------------------------------------------------------------------- the tree
|
|
426
|
+
|
|
427
|
+
def _real_dir(path):
|
|
428
|
+
try:
|
|
429
|
+
info = os.lstat(path)
|
|
430
|
+
except OSError:
|
|
431
|
+
return False
|
|
432
|
+
return stat.S_ISDIR(info.st_mode)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _private_dir(path):
|
|
436
|
+
"""`path` as a real 0700 directory, created when absent; a symbolic link or a non-directory is refused."""
|
|
437
|
+
try:
|
|
438
|
+
os.mkdir(path, 0o700)
|
|
439
|
+
except FileExistsError:
|
|
440
|
+
pass
|
|
441
|
+
if not _real_dir(path):
|
|
442
|
+
raise DocsError('the docs store holds something that is not a directory where a directory belongs')
|
|
443
|
+
if stat.S_IMODE(os.lstat(path).st_mode) != 0o700:
|
|
444
|
+
os.chmod(path, 0o700)
|
|
445
|
+
return path
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _lane_dir(stem, ws, lane):
|
|
449
|
+
"""STATE/docs/<stem>/<ws>/<lane>, created as needed, every component a real 0700 directory."""
|
|
450
|
+
L.STATE.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
path = _private_dir(root())
|
|
452
|
+
for part in (stem, ws, lane):
|
|
453
|
+
path = _private_dir(path / part)
|
|
454
|
+
return path
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _subdirs(path):
|
|
458
|
+
"""The real (never symlinked) subdirectories of `path` whose names do not start with a dot, sorted."""
|
|
459
|
+
try:
|
|
460
|
+
with os.scandir(path) as entries:
|
|
461
|
+
return sorted(entry.name for entry in entries
|
|
462
|
+
if not entry.name.startswith('.') and entry.is_dir(follow_symlinks=False))
|
|
463
|
+
except OSError:
|
|
464
|
+
return []
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _doc_dirs(ws=None, lane=None, doc_id=None):
|
|
468
|
+
"""(stem, ws, lane, id) of every candidate id dir, optionally of one workspace, lane and/or id. A directory the
|
|
469
|
+
walk lists is never a symbolic link; a named ws/lane/id is taken as given and _read_doc refuses a link."""
|
|
470
|
+
if not _real_dir(root()):
|
|
471
|
+
return
|
|
472
|
+
for stem in _subdirs(root()):
|
|
473
|
+
if stem in RESERVED_STEMS:
|
|
474
|
+
continue
|
|
475
|
+
for ws_name in ([ws] if ws is not None else _subdirs(root() / stem)):
|
|
476
|
+
for lane_name in ([lane] if lane is not None else _subdirs(root() / stem / ws_name)):
|
|
477
|
+
lane_path = root() / stem / ws_name / lane_name
|
|
478
|
+
if doc_id is not None:
|
|
479
|
+
if _real_dir(lane_path / doc_id):
|
|
480
|
+
yield stem, ws_name, lane_name, doc_id
|
|
481
|
+
continue
|
|
482
|
+
for name in _subdirs(lane_path):
|
|
483
|
+
if ID_RE.fullmatch(name):
|
|
484
|
+
yield stem, ws_name, lane_name, name
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _read_doc(stem, ws, lane, doc_id):
|
|
488
|
+
"""The meta of one stored document when it is complete and consistent, else None. Every directory component is
|
|
489
|
+
opened without following a link; meta.json is read only when it is a regular file of at most MAX_META bytes;
|
|
490
|
+
the document file must be a regular file of the recorded size."""
|
|
491
|
+
try:
|
|
492
|
+
fd = L.open_real_dir(root(), (stem, ws, lane, doc_id))
|
|
493
|
+
except (OSError, L.LanesError):
|
|
494
|
+
return None
|
|
495
|
+
try:
|
|
496
|
+
raw = L.read_regular_at(fd, META, MAX_META)
|
|
497
|
+
meta = json.loads(raw.decode('utf-8')) if raw is not None else None
|
|
498
|
+
if not _valid_meta(meta, ws, lane, doc_id):
|
|
499
|
+
return None
|
|
500
|
+
name, size = stored_file(meta)
|
|
501
|
+
info = os.stat(name, dir_fd=fd, follow_symlinks=False)
|
|
502
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size != size:
|
|
503
|
+
return None
|
|
504
|
+
return meta
|
|
505
|
+
except (OSError, ValueError):
|
|
506
|
+
return None
|
|
507
|
+
finally:
|
|
508
|
+
os.close(fd)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _valid_meta(meta, ws, lane, doc_id):
|
|
512
|
+
if not isinstance(meta, dict) or meta.get('v') != VERSION:
|
|
513
|
+
return False
|
|
514
|
+
if (meta.get('id'), meta.get('ws'), meta.get('lane')) != (doc_id, ws, lane):
|
|
515
|
+
return False
|
|
516
|
+
size, fors, title, by = meta.get('bytes'), meta.get('for'), meta.get('title'), meta.get('by')
|
|
517
|
+
try:
|
|
518
|
+
check_name(meta.get('name'))
|
|
519
|
+
except DocsError:
|
|
520
|
+
return False
|
|
521
|
+
return (meta.get('kind') in KINDS and isinstance(size, int) and not isinstance(size, bool) and size >= 0
|
|
522
|
+
and isinstance(meta.get('sha256'), str) and bool(_SHA256.match(meta['sha256']))
|
|
523
|
+
and isinstance(meta.get('at'), str) and bool(_AT.match(meta['at']))
|
|
524
|
+
and isinstance(fors, list) and all(isinstance(login, str) for login in fors)
|
|
525
|
+
and (title is None or isinstance(title, str)) and (by is None or isinstance(by, str))
|
|
526
|
+
and isinstance(meta.get('mime'), str) and isinstance(meta.get('source'), str) and _valid_enc(meta))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _encode(document, sort_keys=False):
|
|
530
|
+
return (json.dumps(document, indent=2, ensure_ascii=False, sort_keys=sort_keys) + '\n').encode('utf-8')
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@contextmanager
|
|
534
|
+
def _locked():
|
|
535
|
+
"""Hold docs/.lock (an exclusive flock) for at most LOCK_S seconds of waiting, else Busy."""
|
|
536
|
+
L.STATE.mkdir(parents=True, exist_ok=True)
|
|
537
|
+
_private_dir(root())
|
|
538
|
+
fd = users._acquire(root() / LOCK, LOCK_S)
|
|
539
|
+
if fd is None:
|
|
540
|
+
raise Busy('the docs store is busy; try again')
|
|
541
|
+
try:
|
|
542
|
+
yield
|
|
543
|
+
finally:
|
|
544
|
+
os.close(fd) # closing the descriptor releases the flock
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _taken_names(lane_path):
|
|
548
|
+
"""Every document name already in one lane dir of one login, plus the store's own file names. An encrypted
|
|
549
|
+
document's `<name>.age` takes `<name>` too, so the collision rule is the same with the `age` switch on or off."""
|
|
550
|
+
taken = set(RESERVED_NAMES)
|
|
551
|
+
for doc_id in _subdirs(lane_path):
|
|
552
|
+
try:
|
|
553
|
+
with os.scandir(lane_path / doc_id) as entries:
|
|
554
|
+
for entry in entries:
|
|
555
|
+
if not entry.name.startswith('.'):
|
|
556
|
+
taken.add(entry.name)
|
|
557
|
+
if entry.name.endswith(AGE_SUFFIX):
|
|
558
|
+
taken.add(entry.name[:-len(AGE_SUFFIX)])
|
|
559
|
+
except OSError:
|
|
560
|
+
continue
|
|
561
|
+
return taken
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _unique_name(name, taken):
|
|
565
|
+
"""bin/lanes cmd_upload's rule: `<stem>-<n><suffix>`, n from 1, until the name is free."""
|
|
566
|
+
final, n = name, 1
|
|
567
|
+
path = PurePosixPath(name)
|
|
568
|
+
while final in taken:
|
|
569
|
+
final = f'{path.stem}-{n}{path.suffix}'
|
|
570
|
+
n += 1
|
|
571
|
+
return final
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------------------------------------------------------------------------------------------------------- counts
|
|
575
|
+
|
|
576
|
+
def _lane_count(lane):
|
|
577
|
+
n, newest = 0, None
|
|
578
|
+
for stem, ws, lane_name, doc_id in _doc_dirs(lane=lane):
|
|
579
|
+
meta = _read_doc(stem, ws, lane_name, doc_id)
|
|
580
|
+
if meta is not None:
|
|
581
|
+
n += 1
|
|
582
|
+
newest = meta['at'] if newest is None or meta['at'] > newest else newest
|
|
583
|
+
return {'n': n, 'at': newest} if n else None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _scan_counts():
|
|
587
|
+
counts = {}
|
|
588
|
+
for stem, ws, lane, doc_id in _doc_dirs():
|
|
589
|
+
meta = _read_doc(stem, ws, lane, doc_id)
|
|
590
|
+
if meta is None:
|
|
591
|
+
continue
|
|
592
|
+
entry = counts.setdefault(lane, {'n': 0, 'at': meta['at']})
|
|
593
|
+
entry['n'] += 1
|
|
594
|
+
entry['at'] = max(entry['at'], meta['at'])
|
|
595
|
+
return counts
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _stored_counts():
|
|
599
|
+
"""counts.json when it is a valid {lane id: {n, at}} object, else None (absent, corrupt: rebuild it). Read
|
|
600
|
+
without following a link or blocking on a FIFO."""
|
|
601
|
+
try:
|
|
602
|
+
fd = L.open_real_dir(root(), ())
|
|
603
|
+
except OSError:
|
|
604
|
+
return None
|
|
605
|
+
try:
|
|
606
|
+
raw = L.read_regular_at(fd, COUNTS, MAX_COUNTS)
|
|
607
|
+
counts = json.loads(raw.decode('utf-8')) if raw is not None else None
|
|
608
|
+
except ValueError:
|
|
609
|
+
return None
|
|
610
|
+
finally:
|
|
611
|
+
os.close(fd)
|
|
612
|
+
if not isinstance(counts, dict):
|
|
613
|
+
return None
|
|
614
|
+
for lane, entry in counts.items():
|
|
615
|
+
if not (isinstance(entry, dict) and set(entry) == {'n', 'at'} and isinstance(entry['n'], int)
|
|
616
|
+
and not isinstance(entry['n'], bool) and entry['n'] > 0 and isinstance(entry['at'], str)
|
|
617
|
+
and _AT.match(entry['at']) and isinstance(lane, str) and L.IDENT.match(lane)):
|
|
618
|
+
return None
|
|
619
|
+
return counts
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _write_counts(counts):
|
|
623
|
+
L.atomic_write_if_changed(counts_path(), _encode(counts, sort_keys=True), 0o600)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _update_counts(lane):
|
|
627
|
+
"""Under the lock: the lane's entry recounted from disk (absent at 0); a corrupt file is rebuilt whole."""
|
|
628
|
+
counts = _stored_counts()
|
|
629
|
+
if counts is None:
|
|
630
|
+
counts = _scan_counts()
|
|
631
|
+
else:
|
|
632
|
+
entry = _lane_count(lane)
|
|
633
|
+
if entry is None:
|
|
634
|
+
counts.pop(lane, None)
|
|
635
|
+
else:
|
|
636
|
+
counts[lane] = entry
|
|
637
|
+
_write_counts(counts)
|
|
638
|
+
return counts
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def recount():
|
|
642
|
+
"""Rebuild counts.json from the documents on disk (under the lock) -> the counts. Writes only on a change."""
|
|
643
|
+
with _locked():
|
|
644
|
+
counts = _scan_counts()
|
|
645
|
+
_write_counts(counts)
|
|
646
|
+
return counts
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def read_counts():
|
|
650
|
+
"""counts.json for readers ({} when absent or not valid); never raises."""
|
|
651
|
+
return _stored_counts() or {}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# ------------------------------------------------------------------------------------------------------------- log
|
|
655
|
+
|
|
656
|
+
def _log_field(value):
|
|
657
|
+
if value is None or value == '':
|
|
658
|
+
return '-'
|
|
659
|
+
return _LOG_UNSAFE.sub('?', str(value))[:MAX_COMPONENT + 1]
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def audit(event, login, doc_id, lane, size, sha256, outcome, now=None):
|
|
663
|
+
"""Append one docs.log line (0600): `<at> <login> <event> <id> <lane> <bytes> <sha256> <outcome>`, `-` for a
|
|
664
|
+
field not known; never content, a title or a name. Never raises: a log that cannot be written is said on
|
|
665
|
+
stderr. -> True when the line was written. A docs.log that has reached LOG_ROTATE_BYTES is first renamed to
|
|
666
|
+
docs.log.1 (replacing the one generation kept) and a new one begun — under an exclusive flock of the full file, and
|
|
667
|
+
only while the name still holds that file, so of several writers (docsd's threads, `lanes upload`) exactly one
|
|
668
|
+
rotates it (security review 2026-10-08, F4: before, refusals anyone on the tailnet could cause grew it without
|
|
669
|
+
bound)."""
|
|
670
|
+
line = ' '.join((stamp(_now() if now is None else now), _log_field(login), _log_field(event),
|
|
671
|
+
_log_field(doc_id), _log_field(lane), _log_field(size), _log_field(sha256),
|
|
672
|
+
_log_field(outcome)))
|
|
673
|
+
try:
|
|
674
|
+
L.STATE.mkdir(parents=True, exist_ok=True)
|
|
675
|
+
fd = _open_log()
|
|
676
|
+
try:
|
|
677
|
+
if os.fstat(fd).st_size >= LOG_ROTATE_BYTES:
|
|
678
|
+
_rotate_log(fd)
|
|
679
|
+
os.close(fd)
|
|
680
|
+
fd = None
|
|
681
|
+
fd = _open_log()
|
|
682
|
+
os.write(fd, (line + '\n').encode('ascii', 'replace'))
|
|
683
|
+
finally:
|
|
684
|
+
if fd is not None:
|
|
685
|
+
os.close(fd)
|
|
686
|
+
return True
|
|
687
|
+
except OSError as error:
|
|
688
|
+
print(f'lanes docs: cannot append to {log_path()}: {error.strerror or error}', file=sys.stderr, flush=True)
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _open_log():
|
|
693
|
+
return os.open(log_path(), os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0),
|
|
694
|
+
0o600)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _rotate_log(fd):
|
|
698
|
+
"""Rename docs.log (open as `fd`, at least LOG_ROTATE_BYTES) to docs.log.1. Another writer that rotated first (the
|
|
699
|
+
name no longer holds `fd`'s file) is left alone. The caller closes `fd` and opens docs.log again."""
|
|
700
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
701
|
+
try:
|
|
702
|
+
held, named = os.fstat(fd), os.stat(log_path(), follow_symlinks=False)
|
|
703
|
+
if (held.st_dev, held.st_ino) == (named.st_dev, named.st_ino) and held.st_size >= LOG_ROTATE_BYTES:
|
|
704
|
+
os.replace(log_path(), rotated_log_path())
|
|
705
|
+
except FileNotFoundError:
|
|
706
|
+
pass # renamed away by another writer meanwhile
|
|
707
|
+
finally:
|
|
708
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------------------------------------------------- intake
|
|
712
|
+
|
|
713
|
+
def intake(login, ws, lane, name, reader, *, limit, source, kind='upload', title=None, for_logins=None, by=None):
|
|
714
|
+
"""Store the bytes `reader` (a binary file-like: read(n) -> bytes) yields as a new document of `login` in `ws` /
|
|
715
|
+
`lane` -> its meta dict. Refuses (DocsError, nothing written) an invalid argument, TooLarge past `limit` bytes,
|
|
716
|
+
Busy when the store lock is not free within LOCK_S, EncryptError when the `age` switch is on and age is absent or
|
|
717
|
+
fails; any failure removes what it wrote. One docs.log line either way (outcome `ok`, `invalid`, `too-large`,
|
|
718
|
+
`busy`, `unsafe`, `encrypt-failed` or `error`)."""
|
|
719
|
+
known = {}
|
|
720
|
+
try:
|
|
721
|
+
stem = login_stem(login)
|
|
722
|
+
known['login'] = login
|
|
723
|
+
check_workspace(ws)
|
|
724
|
+
check_lane(lane)
|
|
725
|
+
known['lane'] = lane
|
|
726
|
+
clean = clean_name(name)
|
|
727
|
+
limit, source, kind = _limit(limit), _source(source), _kind(kind)
|
|
728
|
+
title, fors = _title(title), _for(for_logins)
|
|
729
|
+
if by is not None:
|
|
730
|
+
members.check_login(by, 'by')
|
|
731
|
+
recipient, _identity = age_settings()
|
|
732
|
+
except L.LanesError as error:
|
|
733
|
+
audit('intake', known.get('login'), None, known.get('lane'), None, None, 'invalid')
|
|
734
|
+
raise (error if isinstance(error, DocsError) else DocsError(str(error))) from None
|
|
735
|
+
try:
|
|
736
|
+
lane_path = _lane_dir(stem, ws, lane)
|
|
737
|
+
except (DocsError, OSError) as error:
|
|
738
|
+
audit('intake', login, None, lane, None, None, 'unsafe')
|
|
739
|
+
raise (error if isinstance(error, DocsError) else DocsError('the docs store cannot be written')) from None
|
|
740
|
+
now = _now()
|
|
741
|
+
doc_dir = doc_id = size = sha256 = None
|
|
742
|
+
outcome = 'error'
|
|
743
|
+
try:
|
|
744
|
+
doc_dir, doc_id = _new_doc_dir(lane_path, now)
|
|
745
|
+
temporary = doc_dir / f'.incoming-{secrets.token_hex(6)}'
|
|
746
|
+
size, sha256, stored = _stream(reader, temporary, limit, recipient)
|
|
747
|
+
with _locked():
|
|
748
|
+
final = _unique_name(clean, _taken_names(lane_path))
|
|
749
|
+
os.replace(temporary, doc_dir / (final + AGE_SUFFIX if recipient else final))
|
|
750
|
+
at = stamp(now)
|
|
751
|
+
meta = {'v': VERSION, 'id': doc_id, 'ws': ws, 'lane': lane, 'kind': kind, 'title': title, 'name': final,
|
|
752
|
+
'mime': _mime(final), 'bytes': size, 'sha256': sha256, 'at': at, 'by': by, 'for': fors,
|
|
753
|
+
'source': source}
|
|
754
|
+
if recipient:
|
|
755
|
+
meta['enc'] = {'alg': 'age', 'file': final + AGE_SUFFIX, 'bytes': stored, 'recipient': recipient}
|
|
756
|
+
receipt = {'v': VERSION, 'id': doc_id, 'sha256': sha256, 'bytes': size, 'url': URL.format(doc_id),
|
|
757
|
+
'at': at}
|
|
758
|
+
L.atomic_write(doc_dir / RECEIPT, _encode(receipt), 0o600)
|
|
759
|
+
L.atomic_write(doc_dir / META, _encode(meta), 0o600)
|
|
760
|
+
_update_counts(lane)
|
|
761
|
+
outcome = 'ok'
|
|
762
|
+
return meta
|
|
763
|
+
except TooLarge as error:
|
|
764
|
+
outcome, size = 'too-large', error.received
|
|
765
|
+
raise
|
|
766
|
+
except Busy:
|
|
767
|
+
outcome = 'busy'
|
|
768
|
+
raise
|
|
769
|
+
except EncryptError:
|
|
770
|
+
outcome = 'encrypt-failed'
|
|
771
|
+
raise
|
|
772
|
+
finally:
|
|
773
|
+
if outcome != 'ok' and doc_dir is not None:
|
|
774
|
+
shutil.rmtree(doc_dir, ignore_errors=True)
|
|
775
|
+
audit('intake', login, doc_id, lane, size, sha256 if outcome == 'ok' else None, outcome)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _new_doc_dir(lane_path, now):
|
|
779
|
+
"""A new id dir (0700) in `lane_path`, made with an exclusive mkdir -> (path, id)."""
|
|
780
|
+
for _ in range(16):
|
|
781
|
+
doc_id = new_id(now)
|
|
782
|
+
path = lane_path / doc_id
|
|
783
|
+
try:
|
|
784
|
+
os.mkdir(path, 0o700)
|
|
785
|
+
except FileExistsError:
|
|
786
|
+
continue
|
|
787
|
+
os.chmod(path, 0o700)
|
|
788
|
+
return path, doc_id
|
|
789
|
+
raise DocsError('could not mint a free document id')
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _stream(reader, path, limit, recipient=None):
|
|
793
|
+
"""Copy `reader` into a new 0600 file at `path` (O_EXCL, never through a link), at most `limit` bytes, hashing
|
|
794
|
+
on the way -> (bytes, sha256 hex, bytes on disk). Reads at most limit + 1 bytes: one more is TooLarge. With an age
|
|
795
|
+
`recipient` the bytes are hashed as they come and piped through `age -r <recipient>` into the file, so the hash is
|
|
796
|
+
the plaintext's and the plaintext never touches the disk."""
|
|
797
|
+
digest, size = hashlib.sha256(), 0
|
|
798
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0), 0o600)
|
|
799
|
+
with os.fdopen(fd, 'wb') as out:
|
|
800
|
+
sink, process, errors = out, None, None
|
|
801
|
+
if recipient is not None:
|
|
802
|
+
age = age_binary()
|
|
803
|
+
if age is None:
|
|
804
|
+
raise EncryptError('LANES_DOCS_AGE_RECIPIENT is set, but there is no age binary on PATH')
|
|
805
|
+
errors = tempfile.TemporaryFile()
|
|
806
|
+
process = subprocess.Popen([age, '-r', recipient], stdin=subprocess.PIPE, stdout=out.fileno(),
|
|
807
|
+
stderr=errors, close_fds=True)
|
|
808
|
+
sink = process.stdin
|
|
809
|
+
try:
|
|
810
|
+
while True:
|
|
811
|
+
chunk = reader.read(min(CHUNK, limit + 1 - size))
|
|
812
|
+
if not chunk:
|
|
813
|
+
break
|
|
814
|
+
if not isinstance(chunk, (bytes, bytearray)):
|
|
815
|
+
raise DocsError('a document reader yields bytes')
|
|
816
|
+
size += len(chunk)
|
|
817
|
+
if size > limit:
|
|
818
|
+
raise TooLarge(limit, size)
|
|
819
|
+
digest.update(chunk)
|
|
820
|
+
sink.write(chunk)
|
|
821
|
+
if process is not None:
|
|
822
|
+
sink.close()
|
|
823
|
+
process.wait(AGE_S)
|
|
824
|
+
if process.returncode != 0:
|
|
825
|
+
raise _age_error(process, errors, 'encrypt')
|
|
826
|
+
except BrokenPipeError:
|
|
827
|
+
if process is None:
|
|
828
|
+
raise
|
|
829
|
+
process.wait(AGE_S)
|
|
830
|
+
raise _age_error(process, errors, 'encrypt') from None
|
|
831
|
+
except subprocess.TimeoutExpired:
|
|
832
|
+
raise EncryptError('age did not finish in time') from None
|
|
833
|
+
finally:
|
|
834
|
+
if process is not None:
|
|
835
|
+
if process.poll() is None:
|
|
836
|
+
process.kill()
|
|
837
|
+
process.wait()
|
|
838
|
+
if not process.stdin.closed:
|
|
839
|
+
try:
|
|
840
|
+
process.stdin.close()
|
|
841
|
+
except OSError:
|
|
842
|
+
pass
|
|
843
|
+
errors.close()
|
|
844
|
+
out.flush()
|
|
845
|
+
os.fchmod(out.fileno(), 0o600)
|
|
846
|
+
stored = os.fstat(out.fileno()).st_size
|
|
847
|
+
return size, digest.hexdigest(), stored
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# ---------------------------------------------------------------------------------------------------------- readers
|
|
851
|
+
|
|
852
|
+
def _owner(stem, login):
|
|
853
|
+
try:
|
|
854
|
+
return login_stem(login) == stem
|
|
855
|
+
except L.LanesError:
|
|
856
|
+
return False
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _visible(meta, stem, login):
|
|
860
|
+
folded = login.casefold()
|
|
861
|
+
return _owner(stem, login) or not meta['for'] or any(entry.casefold() == folded for entry in meta['for'])
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _check_reader(login):
|
|
865
|
+
try:
|
|
866
|
+
return members.check_login(login)
|
|
867
|
+
except L.LanesError:
|
|
868
|
+
raise DocsError('the login is not valid') from None
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def list_for(login, ws=None, lane=None):
|
|
872
|
+
"""The metas of every document visible to `login` (optionally of one workspace and/or lane), newest first (by
|
|
873
|
+
`at`, then id). Reads every matching meta.json: O(documents)."""
|
|
874
|
+
_check_reader(login)
|
|
875
|
+
try:
|
|
876
|
+
ws = check_workspace(ws) if ws is not None else None
|
|
877
|
+
lane = check_lane(lane) if lane is not None else None
|
|
878
|
+
except L.LanesError as error:
|
|
879
|
+
raise error if isinstance(error, DocsError) else DocsError(str(error)) from None
|
|
880
|
+
found = []
|
|
881
|
+
for stem, ws_name, lane_name, doc_id in _doc_dirs(ws, lane):
|
|
882
|
+
meta = _read_doc(stem, ws_name, lane_name, doc_id)
|
|
883
|
+
if meta is not None and _visible(meta, stem, login):
|
|
884
|
+
found.append(meta)
|
|
885
|
+
return sorted(found, key=lambda meta: (meta['at'], meta['id']), reverse=True)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _find(doc_id, login):
|
|
889
|
+
"""(stem, meta) of the document `doc_id` when `login` may see it, else None."""
|
|
890
|
+
for stem, ws, lane, found in _doc_dirs(doc_id=doc_id):
|
|
891
|
+
meta = _read_doc(stem, ws, lane, found)
|
|
892
|
+
if meta is not None and _visible(meta, stem, login):
|
|
893
|
+
return stem, meta
|
|
894
|
+
return None
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def load(doc_id, login):
|
|
898
|
+
"""(meta, path of the document file) when the document exists and is visible to `login`, else None. Raises
|
|
899
|
+
DocsError for an id or login that is not valid (never a path). For an encrypted document the path is its stored
|
|
900
|
+
`<name>.age` (stored_file)."""
|
|
901
|
+
check_id(doc_id)
|
|
902
|
+
_check_reader(login)
|
|
903
|
+
found = _find(doc_id, login)
|
|
904
|
+
if found is None:
|
|
905
|
+
return None
|
|
906
|
+
stem, meta = found
|
|
907
|
+
return meta, root() / stem / meta['ws'] / meta['lane'] / doc_id / stored_file(meta)[0]
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def remove(doc_id, login):
|
|
911
|
+
"""Remove a document -> its meta. NotFound when it does not exist or `login` cannot see it, Forbidden when it can
|
|
912
|
+
see it but the document is neither in its store nor for it by name (see the module docstring), Busy when the
|
|
913
|
+
lock is not free. The id dir is renamed aside (readers skip dot names) and deleted; the lane's count follows."""
|
|
914
|
+
try:
|
|
915
|
+
_check_reader(login)
|
|
916
|
+
except DocsError:
|
|
917
|
+
audit('remove', None, None, None, None, None, 'invalid')
|
|
918
|
+
raise
|
|
919
|
+
try:
|
|
920
|
+
check_id(doc_id)
|
|
921
|
+
except DocsError:
|
|
922
|
+
audit('remove', login, None, None, None, None, 'invalid')
|
|
923
|
+
raise
|
|
924
|
+
meta, outcome = None, 'error'
|
|
925
|
+
try:
|
|
926
|
+
with _locked():
|
|
927
|
+
found = _find(doc_id, login)
|
|
928
|
+
if found is None:
|
|
929
|
+
outcome = 'not-found'
|
|
930
|
+
raise NotFound('no such document')
|
|
931
|
+
stem, meta = found
|
|
932
|
+
folded = login.casefold()
|
|
933
|
+
if not (_owner(stem, login) or any(entry.casefold() == folded for entry in meta['for'])):
|
|
934
|
+
outcome = 'forbidden'
|
|
935
|
+
raise Forbidden('only the document\'s owner or a login it is for may remove it')
|
|
936
|
+
lane_path = root() / stem / meta['ws'] / meta['lane']
|
|
937
|
+
aside = lane_path / f'.removing-{doc_id}-{secrets.token_hex(4)}'
|
|
938
|
+
os.rename(lane_path / doc_id, aside)
|
|
939
|
+
shutil.rmtree(aside, ignore_errors=True)
|
|
940
|
+
_update_counts(meta['lane'])
|
|
941
|
+
outcome = 'ok'
|
|
942
|
+
return meta
|
|
943
|
+
except Busy:
|
|
944
|
+
outcome = 'busy'
|
|
945
|
+
raise
|
|
946
|
+
finally:
|
|
947
|
+
audit('remove', login, doc_id, meta['lane'] if meta else None, meta['bytes'] if meta else None,
|
|
948
|
+
meta['sha256'] if meta else None, outcome)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def operator_receipt(doc_id):
|
|
952
|
+
"""(meta, receipt) of the stored document `doc_id`, whichever login's store holds it, else None. For the operator
|
|
953
|
+
— the account the store belongs to — so no login's visibility applies: `lanes docs push <id>` (plan W10 W9, the
|
|
954
|
+
stub of the data service's publication hook) prints the receipt. The document must read as complete (_read_doc)
|
|
955
|
+
and its receipt.json must be a regular file of at most MAX_META bytes, opened without following a link, holding a
|
|
956
|
+
`v` 1 object whose `id` is `doc_id`. Reads only; no docs.log line (no content is read). DocsError for an id that
|
|
957
|
+
is not valid (never a path)."""
|
|
958
|
+
check_id(doc_id)
|
|
959
|
+
for stem, ws, lane, found in _doc_dirs(doc_id=doc_id):
|
|
960
|
+
meta = _read_doc(stem, ws, lane, found)
|
|
961
|
+
if meta is None:
|
|
962
|
+
continue
|
|
963
|
+
try:
|
|
964
|
+
fd = L.open_real_dir(root(), (stem, ws, lane, found))
|
|
965
|
+
except (OSError, L.LanesError):
|
|
966
|
+
continue
|
|
967
|
+
try:
|
|
968
|
+
raw = L.read_regular_at(fd, RECEIPT, MAX_META)
|
|
969
|
+
finally:
|
|
970
|
+
os.close(fd)
|
|
971
|
+
try:
|
|
972
|
+
receipt = json.loads(raw.decode('utf-8')) if raw is not None else None
|
|
973
|
+
except ValueError:
|
|
974
|
+
receipt = None
|
|
975
|
+
if isinstance(receipt, dict) and receipt.get('v') == VERSION and receipt.get('id') == doc_id:
|
|
976
|
+
return meta, receipt
|
|
977
|
+
return None
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def main(argv=None):
|
|
981
|
+
"""`python3 docs_store.py check-age` (install/host-install.sh check_docs_age): exit 0 when the `age` switch can work
|
|
982
|
+
here, else 1 with the reason on stderr."""
|
|
983
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
984
|
+
if argv != ['check-age']:
|
|
985
|
+
print('usage: docs_store.py check-age', file=sys.stderr)
|
|
986
|
+
return 2
|
|
987
|
+
problem = check_age()
|
|
988
|
+
if problem:
|
|
989
|
+
print(f'lanes docs: {problem}', file=sys.stderr)
|
|
990
|
+
return 1
|
|
991
|
+
return 0
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
if __name__ == '__main__':
|
|
995
|
+
sys.exit(main())
|