CH-J Server Managerserverbeheer over SSH
Menu
Gepubliceerde bron

CH-J Server Manager

Blader door mappen en bestanden voor een specifieke toepassing release.

Download bron ZIP
CH-J / Server Manager 377 bestanden
core-20261019T111616Z-160a00bfcddc Toegevoegd: · Gebouwd:
remoteEditor.py Toegevoegd: Datum niet geregistreerd 35,7 KB · 993 regels
Rubriek Bestand downloaden
1"""Fixed Linux file helper protocol. Invoked in memory; never installed on the server.
3One helper serves the Monaco editor save, privileged File Manager operations and the
4final publication of SFTP transfers, so every replacement of an existing file follows
5the same rules: staged copy, verified content, original owner/mode/ACL/xattr/SELinux
6metadata, conflict re-check, atomic rename in the target directory, fsync and a final
7verification. All path walks use directory descriptors and O_NOFOLLOW; symbolic links
8are resolved explicitly first so a link (for example sites-enabled/x.conf) is kept and
9its target file is edited. The editor working copy is retained after replacement: only
10a subsequent confirmed cleanup removes it.
11"""
12import base64
13import errno
14import fcntl
15import hashlib
16import json
17import os
18import pwd
19import re
20import secrets
21import stat
22import sys
24LIMIT = 25 * 1024 * 1024
25CHUNK = 1024 * 1024
26MAX_LINKS = 40
27MAX_ENTRIES = 10000
28MAX_REMOVALS = 200000
29REPLACED = False
30NO_XATTR = (errno.ENOTSUP, errno.EOPNOTSUPP)
31NO_DIR_SYNC = (errno.EBADF, errno.EINVAL)
34def fail(code):
35 raise ValueError(code)
38def check_path(path):
39 if not isinstance(path, str) or not path.startswith('/') or len(path) > 4096 or '\x00' in path \
40 or any(p in ('.', '..') for p in path.split('/')):
41 fail('FILE_UNSAFE_PATH')
42 return path
45def b64(data):
46 return base64.b64encode(data).decode('ascii')
49def trusted(fd, privileged):
50 # A privileged walk only refuses directories that any account may rewrite
51 # (world-writable without the sticky bit). Descriptor-relative O_NOFOLLOW
52 # opens already exclude symlink swaps of path components.
53 if privileged:
54 s = os.fstat(fd)
55 if s.st_mode & 0o002 and not s.st_mode & stat.S_ISVTX:
56 fail('FILE_UNSAFE_PRIVILEGED_DIRECTORY')
59def directory(path, privileged=False, readable=False):
60 """Open path without following any symlink. Intermediate components only need
61 search permission (O_PATH); the final directory is opened for reading when
62 possible so it can be listed and fsynced."""
63 check_path(path)
64 parts = [p for p in path.split('/') if p]
65 fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
66 try:
67 trusted(fd, privileged)
68 for index, part in enumerate(parts):
69 last = index == len(parts) - 1
70 flags = os.O_DIRECTORY | os.O_NOFOLLOW
71 try:
72 nxt = os.open(part, flags | (os.O_RDONLY if last else os.O_PATH), dir_fd=fd)
73 except PermissionError:
74 if not last or readable:
75 raise
76 nxt = os.open(part, flags | os.O_PATH, dir_fd=fd)
77 os.close(fd)
78 fd = nxt
79 trusted(fd, privileged)
80 return fd
81 except BaseException:
82 os.close(fd)
83 raise
86def resolve(path, missing='none'):
87 """Canonical path with every symbolic link resolved (at most MAX_LINKS hops).
88 missing='leaf' accepts a missing final component, 'tail' any missing suffix."""
89 check_path(path)
90 pending = [p for p in path.split('/') if p]
91 done = []
92 links = 0
93 absent = False
94 while pending:
95 part = pending.pop(0)
96 if part == '.':
97 continue
98 if part == '..':
99 if done and not absent:
100 done.pop()
101 elif absent:
102 fail('FILE_UNSAFE_PATH')
103 continue
104 if absent:
105 done.append(part)
106 continue
107 current = '/' + '/'.join(done + [part])
108 try:
109 st = os.lstat(current)
110 except FileNotFoundError:
111 if links and not pending and missing == 'none':
112 fail('FILE_SYMLINK_TARGET_MISSING')
113 if (missing == 'leaf' and not pending) or missing == 'tail':
114 absent = True
115 done.append(part)
116 continue
117 raise
118 if stat.S_ISLNK(st.st_mode):
119 links += 1
120 if links > MAX_LINKS:
121 fail('FILE_SYMLINK_LOOP')
122 value = os.readlink(current)
123 if value.startswith('/'):
124 done = []
125 pending = [p for p in value.split('/') if p] + pending
126 else:
127 done.append(part)
128 return '/' + '/'.join(done), links > 0
131def locate(path, privileged=False, missing='none'):
132 """Resolve symlinks, then open the real parent directory safely."""
133 resolved, linked = resolve(path, missing)
134 parent, name = os.path.split(resolved)
135 if not name:
136 fail('FILE_UNSAFE_PATH')
137 return directory(parent, privileged), name, resolved, linked
140def locate_entry(path, privileged=False):
141 """Parent resolved, final component taken literally (rename/remove act on links)."""
142 check_path(path)
143 parent, name = os.path.split(path.rstrip('/') or '/')
144 if not name:
145 fail('FILE_UNSAFE_PATH')
146 resolved, _ = resolve(parent)
147 return directory(resolved, privileged), name, os.path.join(resolved, name)
150def regular(parent, name, owner=None, private=False, single=True):
151 fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
152 s = os.fstat(fd)
153 code = None
154 if not stat.S_ISREG(s.st_mode):
155 code = 'FILE_UNSAFE_TYPE_OR_LINK'
156 elif single and s.st_nlink != 1:
157 # Replacing the inode would silently split content shared with other links.
158 code = 'FILE_HARDLINK_UNSUPPORTED' if owner is None else 'FILE_UNSAFE_TYPE_OR_LINK'
159 elif (owner is not None and s.st_uid != owner) or (private and stat.S_IMODE(s.st_mode) != 0o600):
160 code = 'FILE_UNSAFE_TYPE_OR_LINK'
161 if code:
162 os.close(fd)
163 fail(code)
164 return fd
167def attributes(fd):
168 # Linux ACLs, capabilities and SELinux labels are extended attributes too.
169 # A filesystem without xattr support simply has none to preserve.
170 try:
171 keys = os.listxattr(fd)
172 except OSError as e:
173 if e.errno in NO_XATTR:
174 return {}
175 raise
176 result = {}
177 for key in sorted(keys):
178 try:
179 result[key] = b64(os.getxattr(fd, key))
180 except OSError as e:
181 if e.errno != errno.ENODATA:
182 raise
183 return result
186def describe(fd):
187 s = os.fstat(fd)
188 return dict(dev=s.st_dev, ino=s.st_ino, uid=s.st_uid, gid=s.st_gid, mode=stat.S_IMODE(s.st_mode),
189 size=s.st_size, mtimeNs=str(s.st_mtime_ns), ctimeNs=str(s.st_ctime_ns), xattrs=attributes(fd))
192def snapshot(fd):
193 before = os.fstat(fd)
194 if before.st_size > LIMIT:
195 fail('FILE_TOO_LARGE')
196 os.lseek(fd, 0, os.SEEK_SET)
197 pieces = []
198 length = 0
199 while True:
200 piece = os.read(fd, min(65536, LIMIT + 1 - length))
201 if not piece:
202 break
203 pieces.append(piece)
204 length += len(piece)
205 if length > LIMIT:
206 fail('FILE_TOO_LARGE')
207 data = b''.join(pieces)
208 meta = describe(fd)
209 after = os.fstat(fd)
210 if (before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns):
211 fail('FILE_CONFLICT')
212 meta['hash'] = hashlib.sha256(data).hexdigest()
213 return data, meta
216def lite(s):
217 return dict(dev=s.st_dev, ino=s.st_ino, size=s.st_size, mtimeNs=str(s.st_mtime_ns), ctimeNs=str(s.st_ctime_ns),
218 uid=s.st_uid, gid=s.st_gid, mode=stat.S_IMODE(s.st_mode), nlink=s.st_nlink)
221def kind(s):
222 return 'directory' if stat.S_ISDIR(s.st_mode) else 'symlink' if stat.S_ISLNK(s.st_mode) else 'file' if stat.S_ISREG(s.st_mode) else 'other'
225def stream_hash(fd, out=None, limit=None):
226 os.lseek(fd, 0, os.SEEK_SET)
227 digest = hashlib.sha256()
228 total = 0
229 while True:
230 piece = os.read(fd, CHUNK)
231 if not piece:
232 break
233 digest.update(piece)
234 total += len(piece)
235 if limit is not None and total > limit:
236 fail('FILE_VERIFY_FAILED')
237 if out is not None:
238 view = memoryview(piece)
239 while view:
240 view = view[os.write(out, view):]
241 return digest.hexdigest(), total
244def fsync_dir(fd):
245 try:
246 os.fsync(fd)
247 except OSError as e:
248 if e.errno not in NO_DIR_SYNC:
249 raise
252def protected_metadata(meta):
253 # Copying a content/inode-bound integrity signature would leave invalid
254 # security metadata. Re-signing requires a separate flow.
255 if set(meta['xattrs']) & {'security.ima', 'security.evm'}:
256 fail('FILE_METADATA_UNSUPPORTED')
259def apply_metadata(fd, meta):
260 """Owner first (chown clears setuid/capabilities), then mode, then the exact
261 xattr set of the original. Only differing values are written, so filesystems
262 that refuse no-op changes (and unprivileged saves of own files) still work."""
263 try:
264 s = os.fstat(fd)
265 if (s.st_uid, s.st_gid) != (meta['uid'], meta['gid']):
266 os.fchown(fd, meta['uid'], meta['gid'])
267 if stat.S_IMODE(os.fstat(fd).st_mode) != meta['mode']:
268 os.fchmod(fd, meta['mode'])
269 current = attributes(fd)
270 for key in current:
271 if key not in meta['xattrs']:
272 os.removexattr(fd, key)
273 for key, value in meta['xattrs'].items():
274 if current.get(key) != value:
275 os.setxattr(fd, key, base64.b64decode(value))
276 except PermissionError:
277 fail('FILE_METADATA_PERMISSION')
278 except OSError as e:
279 if e.errno in NO_XATTR:
280 fail('FILE_METADATA_UNSUPPORTED')
281 raise
284def preserved(actual, original):
285 return all(actual[k] == original[k] for k in ('uid', 'gid', 'mode', 'xattrs'))
288def visible(meta):
289 # trusted.* attributes are visible to root only (e.g. XFS mirrors POSIX ACLs in
290 # trusted.SGI_ACL_FILE). A baseline captured by the SSH account cannot contain them,
291 # so conflict detection compares the attributes both sides can see; any xattr change
292 # still updates ctime, which stays part of the comparison.
293 return dict(meta, xattrs={k: v for k, v in meta['xattrs'].items() if not k.startswith('trusted.')})
296def same_state(current, baseline):
297 return visible(current) == visible(baseline)
300def account_home(uid):
301 try:
302 return pwd.getpwuid(uid).pw_dir
303 except KeyError:
304 return None
307def subdir(d, name, uid):
308 created = False
309 try:
310 os.mkdir(name, 0o700, dir_fd=d)
311 created = True
312 except FileExistsError:
313 pass
314 fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=d)
315 try:
316 s = os.fstat(fd)
317 if s.st_uid != uid:
318 if not (created and os.geteuid() == 0):
319 fail('FILE_UNSAFE_WORKSPACE')
320 os.fchown(fd, uid, -1)
321 os.fchmod(fd, 0o700)
322 return fd
323 except BaseException:
324 os.close(fd)
325 raise
328def workspace(uid=None):
329 """Private 0700 working directory of the account: ~/ch-j-sm, or /var/tmp/chj-sm-<uid>
330 when the home directory is missing or not private to the account."""
331 uid = os.getuid() if uid is None else uid
332 home = account_home(uid)
333 h = None
334 if home:
335 try:
336 h = directory(check_path(home), readable=True)
337 hs = os.fstat(h)
338 if hs.st_uid != uid or hs.st_mode & 0o022 or 'system.posix_acl_access' in attributes(h):
339 os.close(h)
340 h = None
341 except (OSError, ValueError):
342 if h is not None:
343 os.close(h)
344 h = None
345 if h is None:
346 home = '/var/tmp'
347 h = directory(home, readable=True)
348 name = 'chj-sm-%d' % uid
349 else:
350 name = 'ch-j-sm'
351 try:
352 return subdir(h, name, uid), os.path.join(home, name), uid
353 finally:
354 os.close(h)
357def operation_id(value):
358 if not re.fullmatch('[a-f0-9]{32}', str(value)):
359 fail('FILE_INVALID_RECOVERY_ID')
360 return value
363def names(value):
364 value = operation_id(value)
365 return '.' + value + '.tmp', '.' + value + '.json'
368def put_record(d, name, value):
369 data = json.dumps(value).encode('ascii')
370 if len(data) > 256 * 1024:
371 fail('FILE_METADATA_UNSUPPORTED')
372 fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=d)
373 with os.fdopen(fd, 'wb') as out:
374 os.fchmod(out.fileno(), 0o600)
375 out.write(data)
376 out.flush()
377 os.fsync(out.fileno())
378 os.fsync(d)
381def get_record(d, name, uid):
382 fd = regular(d, name, uid, True)
383 with os.fdopen(fd, 'rb') as inp:
384 raw = inp.read(256 * 1024 + 1)
385 if len(raw) > 256 * 1024:
386 fail('FILE_INVALID_RECOVERY_RECORD')
387 record = json.loads(raw)
388 if not re.fullmatch('[a-f0-9]{64}', record['hash']):
389 fail('FILE_INVALID_RECOVERY_RECORD')
390 return record
393def inspect_record(d, uid, value, include_text=False):
394 tmp, journal = names(value)
395 r = get_record(d, journal, uid)
396 fd = regular(d, tmp, uid, True)
397 try:
398 data, staged = snapshot(fd)
399 finally:
400 os.close(fd)
401 complete = staged['hash'] == r['hash']
402 state = 'upload-incomplete'
403 if complete:
404 state = 'recovery-available'
405 try:
406 td, name, resolved, _ = locate(r['path'])
407 try:
408 t = regular(td, name)
409 try:
410 _, current = snapshot(t)
411 finally:
412 os.close(t)
413 if current['hash'] == r['hash'] and preserved(visible(current), visible(r['baseline'])):
414 state = 'confirmed'
415 elif not same_state(current, r['baseline']) or resolved != r.get('resolvedPath', resolved):
416 state = 'conflict'
417 finally:
418 os.close(td)
419 except (OSError, ValueError):
420 pass # Permission denied/disconnected from target: copy remains useful.
421 result = dict(recoveryId=value, path=r['path'], status=state, hash=staged['hash'], complete=complete)
422 if include_text:
423 if not complete:
424 fail('FILE_UPLOAD_INCOMPLETE')
425 result['data'] = b64(data)
426 return result
429def direct_save(d, meta):
430 euid = os.geteuid()
431 if euid == 0:
432 return True
433 try:
434 groups = set(os.getgroups()) | {os.getegid()}
435 return meta['uid'] == euid and meta['gid'] in groups and os.access('.', os.W_OK | os.X_OK, dir_fd=d)
436 except OSError:
437 return False
440def backup_copy(ident, data, meta):
441 """Optional rollback copy of the original in the effective account's private
442 workspace (root's own under sudo), never next to the system file."""
443 d, home, _ = workspace(os.geteuid())
444 try:
445 b = subdir(d, 'backups', os.geteuid())
446 try:
447 name = ident + '.orig'
448 fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=b)
449 with os.fdopen(fd, 'wb') as out:
450 out.write(data)
451 out.flush()
452 os.fsync(out.fileno())
453 fsync_dir(b)
454 return os.path.join(home, 'backups', name)
455 finally:
456 os.close(b)
457 finally:
458 os.close(d)
461def finalize(a, d, uid):
462 global REPLACED
463 tmp, _ = names(a['id'])
464 source = regular(d, tmp, uid, True)
465 try:
466 data, staged = snapshot(source)
467 if staged['hash'] != a['hash']:
468 fail('FILE_UPLOAD_INCOMPLETE')
469 os.fsync(source)
470 td, name, resolved, linked = locate(a['path'], os.geteuid() == 0)
471 if a.get('resolvedPath') and a['resolvedPath'] != resolved:
472 os.close(td)
473 fail('FILE_CONFLICT') # The symbolic link now points elsewhere.
474 destination = '.chj-save-' + secrets.token_hex(16) + '.tmp'
475 created = False
476 old = None
477 try:
478 old = regular(td, name)
479 fcntl.flock(old, fcntl.LOCK_EX | fcntl.LOCK_NB)
480 # The full (possibly root) view of the original is the metadata source.
481 original, baseline = snapshot(old)
482 if not same_state(baseline, a['baseline']):
483 fail('FILE_CONFLICT')
484 protected_metadata(baseline)
485 dest = os.open(destination, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=td)
486 created = True
487 try:
488 # Write fully before applying security metadata (capabilities
489 # and setuid bits can be cleared by writes or chown).
490 with os.fdopen(os.dup(dest), 'wb') as out:
491 out.write(data)
492 out.flush()
493 apply_metadata(dest, baseline)
494 _, ready = snapshot(dest)
495 if ready['hash'] != a['hash'] or not preserved(ready, baseline):
496 fail('FILE_METADATA_UNSUPPORTED')
497 os.fsync(dest)
498 # Detect writes, chmod/chown, link changes and path replacement
499 # while preparing. flock serializes cooperating editor saves.
500 _, current = snapshot(old)
501 linked_now = os.stat(name, dir_fd=td, follow_symlinks=False)
502 if current != baseline or linked_now.st_ino != baseline['ino'] or linked_now.st_dev != baseline['dev'] or linked_now.st_nlink != 1:
503 fail('FILE_CONFLICT')
504 backup = backup_copy(a['id'], original, baseline) if a.get('backup') else None
505 os.replace(destination, name, src_dir_fd=td, dst_dir_fd=td)
506 REPLACED = True
507 created = False
508 fsync_dir(td)
509 check_dir, check_name, _, _ = locate(a['path'])
510 try:
511 verified = regular(check_dir, check_name)
512 try:
513 _, final = snapshot(verified)
514 finally:
515 os.close(verified)
516 finally:
517 os.close(check_dir)
518 if final['ino'] != ready['ino'] or final['hash'] != a['hash'] or not preserved(final, baseline):
519 fail('FILE_RESULT_UNCONFIRMED')
520 result = dict(status='saved', baseline=final, hash=final['hash'], resolvedPath=resolved, link=linked)
521 if backup:
522 result['backupPath'] = backup
523 return result
524 finally:
525 os.close(dest)
526 finally:
527 if created:
528 try:
529 os.unlink(destination, dir_fd=td)
530 except OSError:
531 pass
532 if old is not None:
533 os.close(old)
534 os.close(td)
535 finally:
536 os.close(source)
539def make_parents(path):
540 """Create the missing directories of an already resolved path (mode 0755)."""
541 fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
542 try:
543 trusted(fd, os.geteuid() == 0)
544 for part in [p for p in check_path(path).split('/') if p]:
545 try:
546 os.mkdir(part, 0o755, dir_fd=fd)
547 except FileExistsError:
548 pass
549 nxt = os.open(part, os.O_PATH | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
550 os.close(fd)
551 fd = nxt
552 trusted(fd, os.geteuid() == 0)
553 finally:
554 os.close(fd)
557def target_state(td, name):
558 try:
559 s = os.stat(name, dir_fd=td, follow_symlinks=False)
560 except FileNotFoundError:
561 return None
562 if not stat.S_ISREG(s.st_mode):
563 fail('FILE_UNSAFE_TYPE_OR_LINK')
564 return s
567def already_published(td, name, expected, size):
568 s = target_state(td, name)
569 if s is None or s.st_size != size:
570 return False
571 fd = regular(td, name, single=False)
572 try:
573 return stream_hash(fd)[0] == expected
574 finally:
575 os.close(fd)
578def place(td, temp, name, exists):
579 """Atomic replacement of an existing file, no-clobber publication of a new one."""
580 if exists:
581 os.replace(temp, name, src_dir_fd=td, dst_dir_fd=td)
582 return
583 try:
584 os.link(temp, name, src_dir_fd=td, dst_dir_fd=td, follow_symlinks=False)
585 except FileExistsError:
586 fail('FILE_CONFLICT')
587 except OSError as e:
588 if e.errno not in (errno.EPERM, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EMLINK, errno.EXDEV):
589 raise
590 if target_state(td, name) is not None: # Filesystem without hard links.
591 fail('FILE_CONFLICT')
592 os.rename(temp, name, src_dir_fd=td, dst_dir_fd=td)
593 return
594 os.unlink(temp, dir_fd=td)
597def publish(a):
598 """Move a verified, completely transferred file into its destination."""
599 global REPLACED
600 expected, size, uid, strategy = a['hash'], int(a['size']), int(a['uid']), a['strategy']
601 if not re.fullmatch('[a-f0-9]{64}', str(expected)) or strategy not in ('rename', 'copy'):
602 fail('FILE_PROTOCOL_ERROR')
603 new_mode = valid_mode(a.get('mode'), 0o644, 0o777)
604 source = check_path(a['source'])
605 if a.get('parents'):
606 parent, _ = resolve(os.path.dirname(check_path(a['path'])), 'tail')
607 make_parents(parent)
608 td, name, resolved, linked = locate(a['path'], os.geteuid() == 0, 'leaf')
609 sd = src = work = old = None
610 temp = None
611 try:
612 base = a.get('baseline')
613 current = target_state(td, name)
614 if current is not None and current.st_nlink != 1:
615 fail('FILE_HARDLINK_UNSUPPORTED')
616 changed = (current is None) != (base is None) or (current is not None and lite(current) != base)
617 if changed and a.get('retry') and already_published(td, name, expected, size):
618 return dict(status='published', path=a['path'], resolvedPath=resolved, link=linked, size=size, hash=expected,
619 alreadyPublished=True, sourceRemoved=False)
620 if current is not None and base is None and not a.get('overwrite'):
621 fail('FILE_EXISTS')
622 if changed and not (current is not None and base is None):
623 fail('FILE_CONFLICT')
624 meta = None
625 if current is not None:
626 old = regular(td, name)
627 fcntl.flock(old, fcntl.LOCK_EX | fcntl.LOCK_NB)
628 meta = describe(old)
629 protected_metadata(meta)
630 if strategy == 'rename':
631 # Direct transfers are written next to the real target by the SSH account.
632 if os.path.dirname(source) != os.path.dirname(resolved):
633 fail('FILE_PROTOCOL_ERROR')
634 temp = os.path.basename(source)
635 src = work = regular(td, temp, uid, True)
636 digest, length = stream_hash(src)
637 else:
638 # Staged in the account workspace: copy into a new file beside the target so
639 # it receives the directory's default labels/ACLs (new) or the original's.
640 sd = directory(os.path.dirname(source))
641 src = regular(sd, os.path.basename(source), uid, True)
642 temp = '.chj-publish-' + secrets.token_hex(16) + '.tmp'
643 work = os.open(temp, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=td)
644 digest, length = stream_hash(src, work)
645 if digest != expected or length != size:
646 fail('FILE_VERIFY_FAILED')
647 if meta is not None:
648 apply_metadata(work, meta)
649 if not preserved(describe(work), meta):
650 fail('FILE_METADATA_UNSUPPORTED')
651 elif stat.S_IMODE(os.fstat(work).st_mode) != new_mode:
652 os.fchmod(work, new_mode)
653 os.fsync(work)
654 ready = os.fstat(work)
655 if current is not None:
656 now = target_state(td, name)
657 if now is None or lite(now) != lite(current):
658 fail('FILE_CONFLICT')
659 place(td, temp, name, current is not None)
660 REPLACED = True
661 fsync_dir(td)
662 final = target_state(td, name)
663 if final is None or final.st_ino != ready.st_ino or final.st_size != size:
664 fail('FILE_RESULT_UNCONFIRMED')
665 removed = strategy == 'rename'
666 if sd is not None:
667 try:
668 os.unlink(os.path.basename(source), dir_fd=sd)
669 fsync_dir(sd)
670 removed = True
671 except OSError:
672 pass
673 return dict(status='published', path=a['path'], resolvedPath=resolved, link=linked, size=size, hash=expected,
674 created=current is None, metadataPreserved=meta is not None, sourceRemoved=removed)
675 finally:
676 if strategy == 'copy' and work is not None:
677 os.close(work)
678 if not REPLACED:
679 try:
680 os.unlink(temp, dir_fd=td)
681 except OSError:
682 pass
683 for fd in (src, old, sd, td):
684 if fd is not None:
685 os.close(fd)
688def stage_out(a):
689 """Privileged download: copy a protected file into the account's private
690 transfer workspace so the SSH account can fetch it with resumable SFTP."""
691 uid = int(a['uid'])
692 part = operation_id(a['id']) + '.part'
693 sd, sname, resolved, linked = locate(a['path'], os.geteuid() == 0)
694 try:
695 src = regular(sd, sname, single=False)
696 finally:
697 os.close(sd)
698 try:
699 wd, home, _ = workspace(uid)
700 try:
701 td = subdir(wd, 'transfers', uid)
702 finally:
703 os.close(wd)
704 try:
705 try:
706 os.close(regular(td, part, uid, True))
707 os.unlink(part, dir_fd=td)
708 except FileNotFoundError:
709 pass
710 before = os.fstat(src)
711 dest = os.open(part, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=td)
712 try:
713 digest, length = stream_hash(src, dest)
714 after = os.fstat(src)
715 if (before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns) or length != after.st_size:
716 fail('FILE_CONFLICT')
717 if os.geteuid() != uid:
718 try:
719 gid = pwd.getpwuid(uid).pw_gid
720 except KeyError:
721 gid = -1
722 os.fchown(dest, uid, gid)
723 os.fsync(dest)
724 except BaseException:
725 os.close(dest)
726 try:
727 os.unlink(part, dir_fd=td)
728 except OSError:
729 pass
730 raise
731 os.close(dest)
732 fsync_dir(td)
733 return dict(path=os.path.join(home, 'transfers', part), size=length, hash=digest, mtimeNs=str(after.st_mtime_ns),
734 ino=after.st_ino, dev=after.st_dev, resolvedPath=resolved, link=linked, mode=stat.S_IMODE(after.st_mode))
735 finally:
736 os.close(td)
737 finally:
738 os.close(src)
741def remove_tree(parent, name, state):
742 fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
743 try:
744 for entry in os.listdir(fd):
745 state['entries'] += 1
746 if state['entries'] > MAX_REMOVALS:
747 fail('FILE_TOO_MANY_ENTRIES')
748 s = os.stat(entry, dir_fd=fd, follow_symlinks=False)
749 if stat.S_ISDIR(s.st_mode):
750 remove_tree(fd, entry, state)
751 else:
752 os.unlink(entry, dir_fd=fd)
753 finally:
754 os.close(fd)
755 os.rmdir(name, dir_fd=parent)
758def list_directory(a):
759 resolved, linked = resolve(a['path'])
760 d = directory(resolved, readable=True)
761 try:
762 entries = []
763 names_ = sorted(os.listdir(d))
764 for name in names_[:MAX_ENTRIES]:
765 try:
766 s = os.stat(name, dir_fd=d, follow_symlinks=False)
767 except FileNotFoundError:
768 continue
769 entry = dict(name=name, type=kind(s), size=s.st_size, mtime=int(s.st_mtime), mode=stat.S_IMODE(s.st_mode) | stat.S_IFMT(s.st_mode))
770 if stat.S_ISLNK(s.st_mode):
771 try:
772 entry['targetType'] = kind(os.stat(name, dir_fd=d))
773 except OSError:
774 entry['targetType'] = 'missing'
775 entries.append(entry)
776 return dict(path=a['path'], resolvedPath=resolved, link=linked, entries=entries, truncated=len(names_) > MAX_ENTRIES)
777 finally:
778 os.close(d)
781def valid_mode(value, default, mask=0o7777):
782 mode = default if value is None else int(value)
783 if mode < 0 or mode & ~mask:
784 fail('FILE_PROTOCOL_ERROR')
785 return mode
788def file_operation(op, a):
789 privileged = os.geteuid() == 0
790 if op == 'read':
791 d, name, resolved, linked = locate(a['path'])
792 try:
793 fd = regular(d, name)
794 try:
795 data, meta = snapshot(fd)
796 return dict(data=b64(data), baseline=meta, resolvedPath=resolved, link=linked, directSave=direct_save(d, meta))
797 finally:
798 os.close(fd)
799 finally:
800 os.close(d)
801 if op == 'stat':
802 d, name, resolved, linked = locate(a['path'], missing='leaf')
803 try:
804 try:
805 s = os.stat(name, dir_fd=d, follow_symlinks=False)
806 except FileNotFoundError:
807 return dict(exists=False, resolvedPath=resolved, link=linked,
808 writable=os.access('.', os.W_OK | os.X_OK, dir_fd=d))
809 return dict(exists=True, resolvedPath=resolved, link=linked, type=kind(s), baseline=lite(s),
810 writable=os.access('.', os.W_OK | os.X_OK, dir_fd=d) and os.access(name, os.W_OK, dir_fd=d))
811 finally:
812 os.close(d)
813 if op == 'listdir':
814 return list_directory(a)
815 if op == 'hash':
816 d, name, resolved, _ = locate(a['path'])
817 try:
818 fd = regular(d, name, single=False)
819 finally:
820 os.close(d)
821 try:
822 digest, length = stream_hash(fd)
823 return dict(hash=digest, size=length, resolvedPath=resolved)
824 finally:
825 os.close(fd)
826 if op in ('mkdir', 'create'):
827 if a.get('parents'):
828 parent, _ = resolve(os.path.dirname(check_path(a['path'])), 'tail')
829 make_parents(parent)
830 d, name, path = locate_entry(a['path'], privileged)
831 try:
832 if op == 'mkdir':
833 os.mkdir(name, valid_mode(a.get('mode'), 0o755, 0o1777), dir_fd=d)
834 else:
835 fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, valid_mode(a.get('mode'), 0o644, 0o777), dir_fd=d)
836 try:
837 os.fsync(fd)
838 finally:
839 os.close(fd)
840 fsync_dir(d)
841 return dict(path=a['path'], resolvedPath=path)
842 finally:
843 os.close(d)
844 if op == 'rename':
845 sd, sname, _ = locate_entry(a['from'], privileged)
846 try:
847 dd, dname, _ = locate_entry(a['to'], privileged)
848 try:
849 os.stat(sname, dir_fd=sd, follow_symlinks=False)
850 try:
851 os.stat(dname, dir_fd=dd, follow_symlinks=False)
852 fail('FILE_EXISTS')
853 except FileNotFoundError:
854 pass
855 os.rename(sname, dname, src_dir_fd=sd, dst_dir_fd=dd)
856 fsync_dir(dd)
857 fsync_dir(sd)
858 return {'from': a['from'], 'to': a['to']}
859 finally:
860 os.close(dd)
861 finally:
862 os.close(sd)
863 if op == 'remove':
864 d, name, _ = locate_entry(a['path'], privileged)
865 try:
866 s = os.stat(name, dir_fd=d, follow_symlinks=False)
867 state = dict(entries=1)
868 if stat.S_ISDIR(s.st_mode):
869 if a.get('recursive'):
870 remove_tree(d, name, state)
871 else:
872 os.rmdir(name, dir_fd=d)
873 else:
874 os.unlink(name, dir_fd=d)
875 fsync_dir(d)
876 return dict(path=a['path'], type=kind(s), entries=state['entries'])
877 finally:
878 os.close(d)
879 if op == 'publish':
880 return publish(a)
881 if op == 'stage-out':
882 return stage_out(a)
883 return None
886def transfer_operation(op, a, d, home, uid):
887 if op == 'transfer-workspace':
888 os.close(subdir(d, 'transfers', uid))
889 return dict(path=os.path.join(home, 'transfers'), uid=uid)
890 td = subdir(d, 'transfers', uid)
891 try:
892 if op == 'transfer-list':
893 items = []
894 for name in sorted(os.listdir(td))[:MAX_ENTRIES]:
895 if re.fullmatch(r'[a-f0-9]{32}\.part', name):
896 s = os.stat(name, dir_fd=td, follow_symlinks=False)
897 items.append(dict(id=name[:32], size=s.st_size, mtime=int(s.st_mtime)))
898 return dict(path=os.path.join(home, 'transfers'), items=items)
899 if op == 'transfer-remove':
900 part = operation_id(a['id']) + '.part'
901 try:
902 os.close(regular(td, part, uid, True))
903 except FileNotFoundError:
904 return dict(removed=False)
905 os.unlink(part, dir_fd=td)
906 fsync_dir(td)
907 return dict(removed=True)
908 finally:
909 os.close(td)
910 fail('FILE_INVALID_OPERATION')
913def run(a):
914 op = a['operation']
915 result = file_operation(op, a)
916 if result is not None:
917 return result
918 d, home, uid = workspace(a.get('uid') if op != 'prepare' else None)
919 try:
920 if op.startswith('transfer-'):
921 return transfer_operation(op, a, d, home, uid)
922 if op == 'identity':
923 return dict(uid=uid)
924 if op == 'prepare':
925 tmp, journal = names(a['id'])
926 record = dict(path=a['path'], baseline=a['baseline'], hash=a['hash'])
927 if a.get('resolvedPath'):
928 record['resolvedPath'] = a['resolvedPath']
929 put_record(d, journal, record)
930 return dict(temporary=home + '/' + tmp, uid=uid, recoveryId=a['id'])
931 if op == 'list':
932 results = []
933 for name in sorted(os.listdir(d))[:MAX_ENTRIES]:
934 if re.fullmatch(r'\.[a-f0-9]{32}\.json', name):
935 try:
936 results.append(inspect_record(d, uid, name[1:-5]))
937 except (OSError, ValueError, KeyError):
938 results.append(dict(recoveryId=name[1:-5], status='inspection-unavailable'))
939 return dict(items=results)
940 tmp, journal = names(a['id'])
941 if op in ('inspect', 'cleanup'):
942 result = inspect_record(d, uid, a['id'], op == 'inspect')
943 if op == 'cleanup':
944 if result['status'] != 'confirmed':
945 fail('FILE_RESULT_UNCONFIRMED')
946 os.unlink(tmp, dir_fd=d)
947 os.unlink(journal, dir_fd=d)
948 os.fsync(d)
949 return result
950 if op != 'finalize':
951 fail('FILE_INVALID_OPERATION')
952 return finalize(a, d, uid)
953 finally:
954 os.close(d)
957def read_request():
958 if len(sys.argv) > 1:
959 return json.loads(sys.argv[1])
960 # Privileged runs receive the request as a stdin frame after the READY marker.
961 line = sys.stdin.buffer.readline(16 * 1024 * 1024)
962 if not line.startswith(b'CHJREQ:'):
963 fail('FILE_PROTOCOL_ERROR')
964 return json.loads(base64.b64decode(line[7:].strip()))
967ERRNO_CODES = {
968 errno.ELOOP: 'FILE_UNSAFE_PATH', errno.ENOTDIR: 'FILE_UNSAFE_PATH', errno.ENAMETOOLONG: 'FILE_UNSAFE_PATH',
969 errno.ENOSPC: 'FILE_NO_SPACE', errno.EDQUOT: 'FILE_NO_SPACE', errno.EROFS: 'FILE_READ_ONLY_FILESYSTEM',
970 errno.EXDEV: 'FILE_CROSS_DEVICE', errno.ENOTEMPTY: 'FILE_DIRECTORY_NOT_EMPTY', errno.EBUSY: 'FILE_BUSY',
971 errno.ETXTBSY: 'FILE_BUSY', errno.EISDIR: 'FILE_IS_DIRECTORY', errno.EEXIST: 'FILE_EXISTS'
975def respond(code):
976 print(json.dumps(dict(ok=False, code='FILE_RESULT_UNKNOWN' if REPLACED else code)))
979try:
980 if sys.platform != 'linux':
981 fail('FILE_LINUX_REQUIRED')
982 print(json.dumps(dict(ok=True, value=run(read_request()))))
983except BlockingIOError:
984 respond('FILE_CONFLICT')
985except PermissionError:
986 respond('FILE_PERMISSION_DENIED')
987except FileNotFoundError:
988 respond('FILE_NOT_FOUND')
989except OSError as e:
990 respond(ERRNO_CODES.get(e.errno, 'FILE_REMOTE_IO_FAILED'))
991except (ValueError, KeyError, TypeError):
992 e = sys.exc_info()[1]
993 respond(str(e) if re.fullmatch('FILE_[A-Z_]+', str(e)) else 'FILE_PROTOCOL_ERROR')
SHA-256434e716eefdeda79a2c64e672b11b27cc8bdcdfbb0b553de6545f012db1b647f

CH-J Proprietary Software License 1.14 · Bron wordt geleverd onder de CH-J Proprietary Software License 1.14. De beschikbaarheid verandert de licentievoorwaarden niet of verleent geen extra rechten.

Archief SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Bestanden