@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,538 @@
1
+ """lanes uploads — one way in for a file a person uploads (docs cockpit W10 task W5, LNS-106).
2
+
3
+ Both upload paths land the file where the agents have always found it AND record it in the docs store:
4
+ * the SSH droplet `lanes upload` (bin/lanes cmd_upload: a tar stream on stdin from the client's `lanes-upload`, the
5
+ sidebar's upload strip through the upload helper, the watch folder, the macOS droplet) — receive_tar, source `ssh`;
6
+ * the Docs page's `POST /up` (libexec/docsd.py: a multipart form from the phone or laptop) — receive, source `web`.
7
+
8
+ Landing, as cmd_upload always did it: `<folder>/uploads/<name>` in the workspace's container, reached through the
9
+ container's home volume (lanes_lib.host_path), where `<folder>` is the workspace's first lane folder, else its working
10
+ directory (landing). An existing name is never overwritten: the next is `<stem>-1<suffix>`, `-2` … (link_free, the
11
+ rule docs_store names documents by). `uploads/` is created on first use (0755) and, when the folder is a git
12
+ repository (`.git` a directory), `.git/info/exclude` gains the `uploads/` line unless a line already names it, so an
13
+ upload is never committed (ensure_excluded). A file appears under its name only once it is complete (written as
14
+ `.incoming-…`, then linked or renamed into place).
15
+
16
+ Safety (DEF-1890, DEF-1891). The folder and everything in it are the container's to change, and the host writes there
17
+ as the account the agents run as (bin/lanes uid_split: the same uid). So every directory is opened component by
18
+ component without following a link (lanes_lib.open_real_dir: an absolute link would resolve against the HOST's root),
19
+ every file is created O_EXCL|O_NOFOLLOW, a tar stream is unpacked here member by member (unpack; never
20
+ tarfile.extractall, which follows a link an agent plants while it runs), and no git process runs in the repository:
21
+ `git check-ignore` runs the repository's `core.fsmonitor`, a command the container chose, on the host.
22
+
23
+ Recording. Each file's bytes pass through a Tee: every chunk read from the upload is written to the landing file and
24
+ handed to docs_store.intake in the same pass, so the store's sha256 receipt is of exactly the bytes that arrived — never
25
+ of a file an agent could rewrite before it was read. The store document is a COPY, never a hard link to the landed
26
+ file: a link shares one inode with a file the container may write, and an edit there would change the receipt-bound
27
+ bytes under an unchanged receipt (test_uploads.Ownership measures that, and that a link cannot even cross to another
28
+ filesystem). Store facts (record): `kind` by extension (kind_for: md, html, svg, else file), `for` and `by` the
29
+ uploader's login, at most UPLOAD_LIMIT bytes a file — a larger file lands without a store document (intake logs
30
+ `too-large`). An SSH upload from a device that no member record owns lands as before, with a docs.log
31
+ `upload - - <lane> <bytes> <sha256> no-owner` line and no store document. LANES_DOCS_AGE_RECIPIENT encrypts the store
32
+ copy only (docs_store), never the landed file.
33
+ """
34
+ import hashlib
35
+ import os
36
+ import secrets
37
+ import stat
38
+ import tarfile
39
+ from pathlib import PurePosixPath
40
+
41
+ import lanes_lib as L
42
+ import docs_store as D
43
+
44
+ UPLOAD_LIMIT = 32 * 1024 * 1024 # bytes of ONE file the store keeps from an upload (docsd's POST /up body cap)
45
+ MAX_RECORDS = 64 # store documents one SSH upload makes at most (docsd's MAX_PARTS for a form)
46
+ MAX_EXCLUDE = 64 * 1024 # a .git/info/exclude larger than this is not read (nor appended to)
47
+ MAX_LINKS = 4096 # names link_free tries before it gives up
48
+ CHUNK = 64 * 1024
49
+ UPLOADS = 'uploads'
50
+ FILE_MODE, DIR_MODE = 0o644, 0o755 # what a web upload and a new uploads/ get, whatever the caller's umask
51
+ EXCLUDE_BLOCK = b'\n# lanes upload: local intake folder, never committed\nuploads/\n' # cmd_upload's, byte for byte
52
+ EXCLUDES = frozenset(('uploads', 'uploads/', '/uploads', '/uploads/', 'uploads/*', '/uploads/*', 'uploads/**',
53
+ '/uploads/**'))
54
+ KINDS = {'.md': 'md', '.markdown': 'md', '.html': 'html', '.htm': 'html', '.svg': 'svg'}
55
+ _CLOEXEC = getattr(os, 'O_CLOEXEC', 0)
56
+ _DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | _CLOEXEC
57
+ _NEW = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | _CLOEXEC
58
+
59
+
60
+ class UploadError(L.LanesError):
61
+ """A file that could not be stored (both-or-neither, `receive`): `word` is the refusal (busy, too-large,
62
+ encrypt-failed, invalid, no-owner)."""
63
+
64
+ def __init__(self, word):
65
+ super().__init__(f'the upload could not be stored ({word})')
66
+ self.word = word
67
+
68
+
69
+ def kind_for(name):
70
+ """The store kind of an uploaded file: md, html or svg by its extension (case-insensitive), else file."""
71
+ return KINDS.get(PurePosixPath(name or '').suffix.lower(), 'file')
72
+
73
+
74
+ # ----------------------------------------------------------------------------------------------------- the landing
75
+
76
+ def landing(registry, workspace):
77
+ """(folder, home, parts) of `workspace`'s uploads: the container folder they land under (`<folder>/uploads/`) —
78
+ the first lane folder, else the workspace's working directory —, the container's home volume on the host, and the
79
+ folder's components below it. Raises LanesError exactly as lanes_lib.host_path does (an unsafe path, one outside the
80
+ home volume, a container whose home volume is unknown: not running at the last `lanes sync`)."""
81
+ folder = next((lane.get('folder') for lane in workspace.get('lanes') or [] if lane.get('folder')), None) \
82
+ or workspace.get('cwd') or ''
83
+ info = (registry.get('containers') or {}).get(workspace.get('container')) or {}
84
+ L.host_path(info, folder)
85
+ rest = folder[len(L.CONTAINER_HOME) + 1:]
86
+ return folder, info['home'], [part for part in rest.split('/') if part]
87
+
88
+
89
+ def _mkdir(dir_fd, name, mode):
90
+ """mkdir `name` in dir_fd -> True when it made it (an existing entry is left as it is)."""
91
+ try:
92
+ os.mkdir(name, mode, dir_fd=dir_fd)
93
+ return True
94
+ except FileExistsError:
95
+ return False
96
+
97
+
98
+ def open_uploads(home, parts):
99
+ """fd of <home>/<parts…>/uploads, made (0755) when absent; every component a real directory — a symbolic link
100
+ anywhere is refused, never followed —, and the repository's exclude file kept naming it (ensure_excluded). The
101
+ caller closes it. Raises LanesError."""
102
+ try:
103
+ root = L.open_real_dir(home, parts)
104
+ except (OSError, L.LanesError):
105
+ raise L.LanesError('the workspace folder cannot be opened without following a symbolic link') from None
106
+ try:
107
+ try:
108
+ created = _mkdir(root, UPLOADS, 0o700)
109
+ except OSError as error:
110
+ raise L.LanesError(f'uploads/ cannot be made in the workspace folder: {error.strerror or error}') from None
111
+ try:
112
+ fd = os.open(UPLOADS, _DIR, dir_fd=root)
113
+ except OSError:
114
+ raise L.LanesError('uploads/ in the workspace folder is not a directory (a symbolic link is refused)') \
115
+ from None
116
+ if created:
117
+ os.fchmod(fd, DIR_MODE)
118
+ try:
119
+ ensure_excluded(root)
120
+ except BaseException:
121
+ os.close(fd)
122
+ raise
123
+ return fd
124
+ finally:
125
+ os.close(root)
126
+
127
+
128
+ def _ignores_uploads(data):
129
+ return any(line.strip() in EXCLUDES for line in data.decode('utf-8', 'replace').splitlines())
130
+
131
+
132
+ def ensure_excluded(root):
133
+ """Keep the repository folder `root` (an fd) from committing uploads/ -> True when it appended. Only when `.git` is a
134
+ real directory (absent, a linked worktree's `.git` file or a link: nothing, as before): `.git/info/exclude` (made
135
+ when absent) gains EXCLUDE_BLOCK unless a line of it already names uploads/ (EXCLUDES). No git process runs here
136
+ (DEF-1890). Raises LanesError when the exclude file is no regular file or cannot be written: an upload that could be
137
+ committed is not made."""
138
+ try:
139
+ git = os.open('.git', _DIR, dir_fd=root)
140
+ except OSError:
141
+ return False
142
+ try:
143
+ try:
144
+ _mkdir(git, 'info', 0o755)
145
+ info = os.open('info', _DIR, dir_fd=git)
146
+ except OSError:
147
+ raise L.LanesError('.git/info in the workspace folder is not a directory') from None
148
+ try:
149
+ try:
150
+ fd = os.open('exclude', os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK | _CLOEXEC,
151
+ 0o644, dir_fd=info)
152
+ except OSError:
153
+ raise L.LanesError('.git/info/exclude cannot be opened without following a link') from None
154
+ try:
155
+ info_stat = os.fstat(fd)
156
+ if not stat.S_ISREG(info_stat.st_mode) or info_stat.st_size > MAX_EXCLUDE:
157
+ raise L.LanesError('.git/info/exclude is not a plain file of at most 64 KiB')
158
+ data, size = [], 0
159
+ while size <= MAX_EXCLUDE:
160
+ chunk = os.read(fd, CHUNK)
161
+ if not chunk:
162
+ break
163
+ data.append(chunk)
164
+ size += len(chunk)
165
+ if _ignores_uploads(b''.join(data)):
166
+ return False
167
+ os.write(fd, EXCLUDE_BLOCK)
168
+ return True
169
+ except OSError as error:
170
+ raise L.LanesError(f'.git/info/exclude cannot be written: {error.strerror or error}') from None
171
+ finally:
172
+ os.close(fd)
173
+ finally:
174
+ os.close(info)
175
+ finally:
176
+ os.close(git)
177
+
178
+
179
+ def _next(name, n):
180
+ path = PurePosixPath(name)
181
+ return f'{path.stem}-{n}{path.suffix}'
182
+
183
+
184
+ def _exists(dir_fd, name):
185
+ try:
186
+ os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
187
+ return True
188
+ except FileNotFoundError:
189
+ return False
190
+
191
+
192
+ def free_name(dir_fd, name):
193
+ """The first name of `name`'s collision sequence (`name`, `<stem>-1<suffix>`, `-2` …) nothing in dir_fd has."""
194
+ final, n = name, 1
195
+ while _exists(dir_fd, final):
196
+ final, n = _next(name, n), n + 1
197
+ if n > MAX_LINKS:
198
+ raise L.LanesError('no free name in uploads/')
199
+ return final
200
+
201
+
202
+ def link_free(dir_fd, source, name, source_dir=None):
203
+ """Hard-link `source` (an entry of source_dir, default dir_fd; never followed when it is a link) to the first free
204
+ name of `name`'s collision sequence in dir_fd -> the name used. link(2) never replaces an existing name, so a name
205
+ taken in the meantime moves on to the next one: an earlier upload is never overwritten."""
206
+ final, n = name, 1
207
+ while True:
208
+ try:
209
+ os.link(source, final, src_dir_fd=dir_fd if source_dir is None else source_dir, dst_dir_fd=dir_fd,
210
+ follow_symlinks=False)
211
+ return final
212
+ except FileExistsError:
213
+ final, n = _next(name, n), n + 1
214
+ if n > MAX_LINKS:
215
+ raise L.LanesError('no free name in uploads/') from None
216
+
217
+
218
+ def rename_free(source_dir, source, dir_fd, name):
219
+ """Rename the directory `source` (in source_dir) to the first free name of `name`'s sequence in dir_fd -> the name
220
+ used. A name some entry has is skipped (rename would replace an empty directory)."""
221
+ final, n = name, 1
222
+ while True:
223
+ if not _exists(dir_fd, final):
224
+ try:
225
+ os.rename(source, final, src_dir_fd=source_dir, dst_dir_fd=dir_fd)
226
+ return final
227
+ except OSError as error:
228
+ if error.errno not in (17, 39): # EEXIST, ENOTEMPTY: taken meanwhile
229
+ raise
230
+ final, n = _next(name, n), n + 1
231
+ if n > MAX_LINKS:
232
+ raise L.LanesError('no free name in uploads/')
233
+
234
+
235
+ def remove_tree(dir_fd, name):
236
+ """Remove the entry `name` of dir_fd and, for a directory, everything in it — never through a link."""
237
+ try:
238
+ info = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
239
+ except FileNotFoundError:
240
+ return
241
+ if not stat.S_ISDIR(info.st_mode):
242
+ os.unlink(name, dir_fd=dir_fd)
243
+ return
244
+ fd = os.open(name, _DIR, dir_fd=dir_fd)
245
+ try:
246
+ for entry in list(os.scandir(fd)):
247
+ remove_tree(fd, entry.name)
248
+ finally:
249
+ os.close(fd)
250
+ os.rmdir(name, dir_fd=dir_fd)
251
+
252
+
253
+ # ------------------------------------------------------------------------------------------------------- recording
254
+
255
+ class Tee:
256
+ """A reader over `source` that writes every chunk it hands out to the fd `out` and hashes it on the way."""
257
+
258
+ def __init__(self, source, out):
259
+ self.source, self.out, self.size, self.digest = source, out, 0, hashlib.sha256()
260
+
261
+ def read(self, size=-1):
262
+ chunk = self.source.read(size if size and size > 0 else CHUNK)
263
+ if chunk:
264
+ view = memoryview(chunk)
265
+ while view:
266
+ view = view[os.write(self.out, view):]
267
+ self.digest.update(chunk)
268
+ self.size += len(chunk)
269
+ return chunk
270
+
271
+ def drain(self):
272
+ """Read the rest (written to `out` too) -> (bytes, sha256 hex) of everything that passed."""
273
+ while self.read(CHUNK):
274
+ pass
275
+ return self.size, self.digest.hexdigest()
276
+
277
+
278
+ def record(tee, *, login, ws, lane, name, source):
279
+ """Hand the bytes `tee` yields to docs_store.intake as a document of `login` in `ws`/`lane` named `name` — kind
280
+ by extension, for and by the login, at most UPLOAD_LIMIT bytes — then drain `tee`, so the landed file is complete
281
+ whatever the store said -> (meta or None, problem word or None): too-large, busy, encrypt-failed, invalid (intake
282
+ logged each), no-owner (no login: a docs.log `upload` line with the bytes and sha256 only), no-lane."""
283
+ meta, problem = None, None
284
+ if login is None or lane is None:
285
+ problem = 'no-owner' if login is None else 'no-lane'
286
+ else:
287
+ try:
288
+ meta = D.intake(login, ws, lane, name, tee, limit=UPLOAD_LIMIT, source=source, kind=kind_for(name),
289
+ for_logins=[login], by=login)
290
+ except D.TooLarge:
291
+ problem = 'too-large'
292
+ except D.Busy:
293
+ problem = 'busy'
294
+ except D.EncryptError:
295
+ problem = 'encrypt-failed'
296
+ except D.DocsError:
297
+ problem = 'invalid'
298
+ size, sha256 = tee.drain()
299
+ if meta is None and problem in ('no-owner', 'no-lane'):
300
+ D.audit('upload', login, None, lane, size, sha256, problem)
301
+ return meta, problem
302
+
303
+
304
+ def receive(uploads, name, reader, *, login, ws, lane, source):
305
+ """One file into the uploads/ directory `uploads` (an fd) AND the store, both or neither (POST /up) -> (meta, the
306
+ name it landed under). Written as `.incoming-<hex>` while the store takes the same bytes, then linked to `name`'s
307
+ first free name. Raises UploadError(word) when the store refused it (nothing lands), OSError when it could not land
308
+ (the store document is removed again)."""
309
+ temporary = f'.incoming-{secrets.token_hex(8)}'
310
+ fd = os.open(temporary, _NEW, 0o600, dir_fd=uploads)
311
+ try:
312
+ meta, problem = record(Tee(reader, fd), login=login, ws=ws, lane=lane, name=name, source=source)
313
+ if meta is None:
314
+ raise UploadError(problem)
315
+ try:
316
+ os.fchmod(fd, FILE_MODE)
317
+ final = link_free(uploads, temporary, name)
318
+ except BaseException:
319
+ try:
320
+ D.remove(meta['id'], login)
321
+ except D.DocsError:
322
+ pass
323
+ raise
324
+ return meta, final
325
+ finally:
326
+ os.close(fd)
327
+ try:
328
+ os.unlink(temporary, dir_fd=uploads)
329
+ except OSError:
330
+ pass
331
+
332
+
333
+ # --------------------------------------------------------------------------------------------- the SSH tar stream
334
+
335
+ def member_parts(name):
336
+ """The path components of a tar member name below the upload: leading `/` and `.` components dropped (as tar's
337
+ data filter strips a leading slash), None for a name with `..` or a NUL (refused)."""
338
+ if not isinstance(name, str) or '\0' in name:
339
+ return None
340
+ parts = [part for part in name.split('/') if part not in ('', '.')]
341
+ return None if '..' in parts else parts
342
+
343
+
344
+ def _file_mode(mode):
345
+ return (mode & 0o755) | 0o600 # tar's data filter: no setuid/setgid/sticky, no group/other write, owner rw
346
+
347
+
348
+ def _dir_mode(mode):
349
+ return (mode & 0o755) | 0o700
350
+
351
+
352
+ def _dirs(base, parts, create=True):
353
+ """fd of base/<parts…>, each a real directory (made 0755 when absent and `create`); never through a link."""
354
+ fd = os.dup(base)
355
+ try:
356
+ for part in parts:
357
+ if create and _mkdir(fd, part, 0o700):
358
+ made = os.open(part, _DIR, dir_fd=fd)
359
+ os.fchmod(made, DIR_MODE)
360
+ os.close(fd)
361
+ fd = made
362
+ continue
363
+ child = os.open(part, _DIR, dir_fd=fd)
364
+ os.close(fd)
365
+ fd = child
366
+ except BaseException:
367
+ os.close(fd)
368
+ raise
369
+ return fd
370
+
371
+
372
+ def _create(parent, name, make):
373
+ """make(parent, name) for a new entry; an entry of that name an earlier member made is replaced (a tar stream may
374
+ carry a path twice, and extraction keeps the last)."""
375
+ try:
376
+ return make(parent, name)
377
+ except FileExistsError:
378
+ os.unlink(name, dir_fd=parent)
379
+ return make(parent, name)
380
+
381
+
382
+ def _link_inside(parts, target):
383
+ """Whether a symbolic link at `parts` pointing at `target` stays inside the upload (relative, never above it)."""
384
+ if not target or target.startswith('/') or '\0' in target:
385
+ return False
386
+ depth = len(parts) - 1
387
+ for part in target.split('/'):
388
+ if part == '..':
389
+ depth -= 1
390
+ if depth < 0:
391
+ return False
392
+ elif part not in ('', '.'):
393
+ depth += 1
394
+ return True
395
+
396
+
397
+ def unpack(stream, staging, on_file):
398
+ """Unpack the tar stream `stream` into the directory `staging` (an fd) member by member -> the top-level names in
399
+ the order they came. Directories and regular files are made without following a link; a regular file's bytes go
400
+ through a Tee into it, and on_file(parts, tee) is called while they stream (it must drain the tee); a symbolic
401
+ link is kept only when it stays inside the upload and a hard link only to a file of it; a device, FIFO or any other
402
+ member is refused (LanesError), as tar's data filter refuses it. An empty stream is no member."""
403
+ tops = []
404
+ try:
405
+ archive = tarfile.open(fileobj=stream, mode='r|')
406
+ except tarfile.ReadError as error:
407
+ if 'empty' in str(error):
408
+ return tops
409
+ raise L.LanesError('the upload is not a tar stream') from None
410
+ with archive:
411
+ try:
412
+ for member in archive:
413
+ parts = member_parts(member.name)
414
+ if parts is None:
415
+ raise L.LanesError('refused: an archive member climbs out of the upload')
416
+ if not parts:
417
+ continue
418
+ if parts[0] not in tops:
419
+ tops.append(parts[0])
420
+ if member.isdir():
421
+ fd = _dirs(staging, parts)
422
+ os.fchmod(fd, _dir_mode(member.mode))
423
+ os.close(fd)
424
+ continue
425
+ parent = _dirs(staging, parts[:-1])
426
+ try:
427
+ if member.isreg():
428
+ fd = _create(parent, parts[-1], lambda p, n: os.open(n, _NEW, 0o600, dir_fd=p))
429
+ try:
430
+ on_file(parts, Tee(archive.extractfile(member), fd))
431
+ os.fchmod(fd, _file_mode(member.mode))
432
+ os.utime(fd, (member.mtime, member.mtime))
433
+ finally:
434
+ os.close(fd)
435
+ elif member.issym():
436
+ if not _link_inside(parts, member.linkname):
437
+ raise L.LanesError('refused: an archive link points outside the upload')
438
+ _create(parent, parts[-1], lambda p, n: os.symlink(member.linkname, n, dir_fd=p))
439
+ elif member.islnk():
440
+ target = member_parts(member.linkname)
441
+ if not target:
442
+ raise L.LanesError('refused: an archive hard link points outside the upload')
443
+ source = _dirs(staging, target[:-1], create=False)
444
+ try:
445
+ _create(parent, parts[-1], lambda p, n: os.link(target[-1], n, src_dir_fd=source,
446
+ dst_dir_fd=p, follow_symlinks=False))
447
+ finally:
448
+ os.close(source)
449
+ else:
450
+ raise L.LanesError('refused: the archive holds a device, a FIFO or another special file')
451
+ finally:
452
+ os.close(parent)
453
+ except (tarfile.TarError, EOFError):
454
+ raise L.LanesError('the upload is not a whole tar stream') from None
455
+ except OSError as error:
456
+ raise L.LanesError(f'the upload could not be unpacked: {error.strerror or error}') from None
457
+ return tops
458
+
459
+
460
+ def place(staging, name, uploads):
461
+ """Move the top-level entry `name` of `staging` into `uploads` under its first free name -> that name: a file (or
462
+ a link) by link(2) — the same inode, no copy —, a directory by rename. Both directories are on the volume."""
463
+ info = os.stat(name, dir_fd=staging, follow_symlinks=False)
464
+ if stat.S_ISDIR(info.st_mode):
465
+ return rename_free(staging, name, uploads, name)
466
+ final = link_free(uploads, name, name, source_dir=staging)
467
+ os.unlink(name, dir_fd=staging)
468
+ return final
469
+
470
+
471
+ def upload_lane(workspace, device=None):
472
+ """The lane an SSH upload to `workspace` is recorded under: the lane `device`'s first viewport shows on this board
473
+ (views/<device>/<ws>.main.target, the lane `--paste` types into), else the board's default lane (defaults/<ws>),
474
+ else its first lane; None for a board without lanes."""
475
+ ids = [lane['id'] for lane in workspace.get('lanes') or [] if isinstance(lane, dict)
476
+ and isinstance(lane.get('id'), str)]
477
+ paths = [L.STATE / 'views' / device / f"{workspace['id']}.main.target"] if device else []
478
+ for path in paths + [L.STATE / 'defaults' / workspace['id']]:
479
+ try:
480
+ first = path.read_text().split('\n')[0].strip()
481
+ except (OSError, UnicodeDecodeError):
482
+ continue
483
+ if first in ids:
484
+ return first
485
+ return ids[0] if ids else None
486
+
487
+
488
+ def receive_tar(stream, uploads, *, login, ws, lane, say=None):
489
+ """An SSH upload: the tar stream `stream` into the uploads/ directory `uploads` (an fd) and the store -> the names
490
+ that landed in uploads/, sorted (cmd_upload's order). Unpacked into a fresh `.incoming-<pid>-<hex>` there, each
491
+ regular file recorded while it streams — a top-level file under the name it is about to land as, a file of a
492
+ folder under its own name; at most MAX_RECORDS documents, the rest land with a docs.log `too-many` line —, then
493
+ every top-level entry placed. A stream that fails part-way lands nothing, as before, and the documents it had
494
+ already recorded are removed again. say(text) hears one line per file (its receipt, or why the store has none)."""
495
+ staging = f'.incoming-{os.getpid()}-{secrets.token_hex(4)}'
496
+ try:
497
+ os.mkdir(staging, 0o700, dir_fd=uploads)
498
+ except OSError as error:
499
+ raise L.LanesError(f'uploads/ cannot be written: {error.strerror or error}') from None
500
+ landed, recorded = [], []
501
+ try:
502
+ staging_fd = os.open(staging, _DIR, dir_fd=uploads)
503
+ try:
504
+ def on_file(parts, tee):
505
+ name = free_name(uploads, parts[0]) if len(parts) == 1 else parts[-1]
506
+ if len(recorded) >= MAX_RECORDS:
507
+ size, sha256 = tee.drain()
508
+ D.audit('upload', login, None, lane, size, sha256, 'too-many')
509
+ problem, meta = 'too-many', None
510
+ else:
511
+ meta, problem = record(tee, login=login, ws=ws, lane=lane, name=name, source='ssh')
512
+ if meta is not None:
513
+ recorded.append(meta)
514
+ if say:
515
+ say(f'# docs: {meta["name"]} {meta["bytes"]} B sha256 {meta["sha256"]} {meta["id"]}' if meta
516
+ else f'# docs: {name}: no receipt ({problem})')
517
+ try:
518
+ tops = unpack(stream, staging_fd, on_file)
519
+ except BaseException:
520
+ for meta in recorded:
521
+ try:
522
+ D.remove(meta['id'], login)
523
+ except D.DocsError:
524
+ pass
525
+ raise
526
+ for name in sorted(tops):
527
+ try:
528
+ landed.append(place(staging_fd, name, uploads))
529
+ except OSError as error:
530
+ raise L.LanesError(f'{name} could not be moved into uploads/: {error.strerror or error}') from None
531
+ finally:
532
+ os.close(staging_fd)
533
+ finally:
534
+ try:
535
+ remove_tree(uploads, staging)
536
+ except OSError:
537
+ pass
538
+ return landed
@@ -0,0 +1,29 @@
1
+ [Unit]
2
+ Description=lanes docs service (login-bound docs, artifacts and uploads) on loopback 127.0.0.1:8185
3
+
4
+ # Enabled and restarted by every server install (install/host-install.sh). lanes-mobile's Caddy forwards
5
+ # https://<host>:8443/lanes-docs/* here (web/Caddyfile `handle_path /lanes-docs/*`). It reads and writes the docs store
6
+ # ~/.local/state/lanes/docs/ and docs.log and reads members/, enrol-mode and registry.json; its publish pick-up (W4)
7
+ # reads each workspace container's ~/.lz/state/publish/ through /proc/<pid>/root, as lanes-statusd does, and writes the
8
+ # receipts back there; and (W5) it lands uploads in a workspace's uploads/ through the container's home volume. Hence
9
+ # it has no ProtectHome.
10
+ # It answers nothing until the operator pins the Serve header check (`lanes enrol mode request`, read on every
11
+ # request). Optional settings (LANES_DOCS_DOMAINS, LANES_DOCS_PORT, and the default-off encryption at rest
12
+ # LANES_DOCS_AGE_RECIPIENT / LANES_DOCS_AGE_IDENTITY, which `lanes upload` reads from the same drop-in) go in a drop-in,
13
+ # `systemctl --user edit lanes-docsd.service`, which a re-install keeps (RUNBOOK "Docs service").
14
+ # Memory: docsd holds at most two 32 MiB buffers at once (UPLOAD_SLOTS, shared by uploads and decryptions, one per
15
+ # login), two Markdown renders and an 8 MiB render cache, and its publish pick-up reads at most 4,096 directory entries
16
+ # of a container per cycle, so what a container plants or a member sends stays under MemoryMax (RUNBOOK "Docs service",
17
+ # Limits; the composed peak is not measured: DEF-1917).
18
+ [Service]
19
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/docsd.py"
20
+ Restart=on-failure
21
+ RestartSec=10
22
+ NoNewPrivileges=true
23
+ UMask=0077
24
+ MemoryHigh=128M
25
+ MemoryMax=192M
26
+ Slice=lanes.slice
27
+
28
+ [Install]
29
+ WantedBy=default.target
@@ -8,12 +8,35 @@
8
8
  http://:8083 {
9
9
  bind 127.0.0.1
10
10
  root * "{$LANES_REPO}/web"
11
- header {
11
+ # lanes-docsd answers /lanes-docs/* with its own security headers per route (an agent's HTML or SVG artifact is a
12
+ # sandboxed top-level document), so the site-wide set below skips that prefix: docsd's are the only ones it sends.
13
+ @notdocs not path /lanes-docs/*
14
+ header @notdocs {
12
15
  X-Content-Type-Options nosniff
13
16
  X-Frame-Options DENY
14
17
  Referrer-Policy strict-origin-when-cross-origin
15
18
  Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; img-src 'self' data:"
16
19
  }
20
+ # lanes-docsd.service (libexec/docsd.py): the docs cockpit, login-bound pages, downloads, artifacts and uploads.
21
+ # handle_path strips /lanes-docs. It stays the FIRST handle of the site: Caddy never reaches a block declared after
22
+ # a handle with a named matcher (the Zellij routes below).
23
+ handle_path /lanes-docs/* {
24
+ reverse_proxy 127.0.0.1:8185 {
25
+ # As on /enrol/*: keep the X-Forwarded-For Tailscale Serve sets (the client's tailnet address): docsd
26
+ # answers only a request that carries one, and only with Serve's identity headers. Caddy >= 2.5.
27
+ trusted_proxies 127.0.0.0/8
28
+ }
29
+ }
30
+ # The docs page's pinned Mermaid (plan W10 W6, LNS-107): install/enable-docs.sh puts holders.lock's pin into
31
+ # $LANES_SHARE/vendor (services.py sets LANES_SHARE in lanes-mobile.service). Immutable: a page names a VERSIONED
32
+ # file, so a new pin is a new URL (DEF-1870). Caddy sorts a single-path route ahead of /lanes-assets/* only past
33
+ # routes that also have a single path (Caddy issue #5037; DEF-1872): keep this block with the single-path routes
34
+ # above the named-matcher ones (/lanes-docs/* does not overlap it, so their order between themselves is free).
35
+ handle_path /lanes-assets/vendor/* {
36
+ root * "{$LANES_SHARE}/vendor"
37
+ header Cache-Control "public, max-age=31536000, immutable"
38
+ file_server
39
+ }
17
40
  handle_path /lanes-assets/* {
18
41
  header Cache-Control "no-cache"
19
42
  file_server