@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +4 -3
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +301 -45
  5. package/payload/lanes/container/lane +42 -1
  6. package/payload/lanes/container/lane-mcp +65 -6
  7. package/payload/lanes/container/lane_core.py +178 -0
  8. package/payload/lanes/docs/ARCHITECTURE.md +192 -20
  9. package/payload/lanes/docs/DESIGN.md +6 -3
  10. package/payload/lanes/docs/INSTALL.md +25 -8
  11. package/payload/lanes/docs/RUNBOOK.md +369 -8
  12. package/payload/lanes/docs/USERGUIDE.md +201 -25
  13. package/payload/lanes/holders.lock +5 -0
  14. package/payload/lanes/install/enable-docs.sh +78 -0
  15. package/payload/lanes/install/host-install.sh +39 -5
  16. package/payload/lanes/libexec/docs_store.py +995 -0
  17. package/payload/lanes/libexec/docsd.py +2010 -0
  18. package/payload/lanes/libexec/mdlite.py +575 -0
  19. package/payload/lanes/libexec/members.py +5 -0
  20. package/payload/lanes/libexec/notify.py +65 -5
  21. package/payload/lanes/libexec/sidebar.py +106 -13
  22. package/payload/lanes/libexec/statusd.py +97 -5
  23. package/payload/lanes/libexec/tokens.py +31 -9
  24. package/payload/lanes/libexec/update.py +94 -22
  25. package/payload/lanes/libexec/uploads.py +538 -0
  26. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  27. package/payload/lanes/web/Caddyfile +24 -1
  28. package/payload/lanes/web/README.md +86 -8
  29. package/payload/lanes/web/browser.js +2 -4
  30. package/payload/lanes/web/docs.css +52 -0
  31. package/payload/lanes/web/docs.js +36 -0
  32. package/payload/lanes/web/mobile.js +3 -2
  33. package/payload/lanes/web/upload.css +17 -0
  34. package/payload/lanes/web/upload.js +84 -0
  35. 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())