公開済みのソース
ソースの ZIP をダウンロード
CH-J Server Manager
特定のアプリケーションリリースのディレクトリとファイルを閲覧できます。
CH-J / Server Manager
377 ファイル
core-20261019T111616Z-160a00bfcddc
追加日時: · ビルド日時:
1
"""Fixed Linux file helper protocol. Invoked in memory; never installed on the server.3
One helper serves the Monaco editor save, privileged File Manager operations and the4
final publication of SFTP transfers, so every replacement of an existing file follows5
the same rules: staged copy, verified content, original owner/mode/ACL/xattr/SELinux6
metadata, conflict re-check, atomic rename in the target directory, fsync and a final7
verification. All path walks use directory descriptors and O_NOFOLLOW; symbolic links8
are resolved explicitly first so a link (for example sites-enabled/x.conf) is kept and9
its target file is edited. The editor working copy is retained after replacement: only10
a subsequent confirmed cleanup removes it.11
"""12
import base6413
import errno14
import fcntl15
import hashlib16
import json17
import os18
import pwd19
import re20
import secrets21
import stat22
import sys24
LIMIT = 25 * 1024 * 102425
CHUNK = 1024 * 102426
MAX_LINKS = 4027
MAX_ENTRIES = 1000028
MAX_REMOVALS = 20000029
REPLACED = False30
NO_XATTR = (errno.ENOTSUP, errno.EOPNOTSUPP)31
NO_DIR_SYNC = (errno.EBADF, errno.EINVAL)34
def fail(code):35
raise ValueError(code)38
def 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 path45
def b64(data):46
return base64.b64encode(data).decode('ascii')49
def trusted(fd, privileged):50
# A privileged walk only refuses directories that any account may rewrite51
# (world-writable without the sticky bit). Descriptor-relative O_NOFOLLOW52
# 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')59
def directory(path, privileged=False, readable=False):60
"""Open path without following any symlink. Intermediate components only need61
search permission (O_PATH); the final directory is opened for reading when62
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) - 170
flags = os.O_DIRECTORY | os.O_NOFOLLOW71
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
raise76
nxt = os.open(part, flags | os.O_PATH, dir_fd=fd)77
os.close(fd)78
fd = nxt79
trusted(fd, privileged)80
return fd81
except BaseException:82
os.close(fd)83
raise86
def 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 = 093
absent = False94
while pending:95
part = pending.pop(0)96
if part == '.':97
continue98
if part == '..':99
if done and not absent:100
done.pop()101
elif absent:102
fail('FILE_UNSAFE_PATH')103
continue104
if absent:105
done.append(part)106
continue107
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 = True115
done.append(part)116
continue117
raise118
if stat.S_ISLNK(st.st_mode):119
links += 1120
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] + pending126
else:127
done.append(part)128
return '/' + '/'.join(done), links > 0131
def 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, linked140
def 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)150
def 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 = None154
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 fd167
def 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
raise176
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
raise183
return result186
def 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))192
def 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 = 0199
while True:200
piece = os.read(fd, min(65536, LIMIT + 1 - length))201
if not piece:202
break203
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, meta216
def 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)221
def 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'225
def stream_hash(fd, out=None, limit=None):226
os.lseek(fd, 0, os.SEEK_SET)227
digest = hashlib.sha256()228
total = 0229
while True:230
piece = os.read(fd, CHUNK)231
if not piece:232
break233
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(), total244
def fsync_dir(fd):245
try:246
os.fsync(fd)247
except OSError as e:248
if e.errno not in NO_DIR_SYNC:249
raise252
def protected_metadata(meta):253
# Copying a content/inode-bound integrity signature would leave invalid254
# security metadata. Re-signing requires a separate flow.255
if set(meta['xattrs']) & {'security.ima', 'security.evm'}:256
fail('FILE_METADATA_UNSUPPORTED')259
def apply_metadata(fd, meta):260
"""Owner first (chown clears setuid/capabilities), then mode, then the exact261
xattr set of the original. Only differing values are written, so filesystems262
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
raise284
def preserved(actual, original):285
return all(actual[k] == original[k] for k in ('uid', 'gid', 'mode', 'xattrs'))288
def visible(meta):289
# trusted.* attributes are visible to root only (e.g. XFS mirrors POSIX ACLs in290
# 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 change292
# 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.')})296
def same_state(current, baseline):297
return visible(current) == visible(baseline)300
def account_home(uid):301
try:302
return pwd.getpwuid(uid).pw_dir303
except KeyError:304
return None307
def subdir(d, name, uid):308
created = False309
try:310
os.mkdir(name, 0o700, dir_fd=d)311
created = True312
except FileExistsError:313
pass314
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 fd323
except BaseException:324
os.close(fd)325
raise328
def 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 uid332
home = account_home(uid)333
h = None334
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 = None341
except (OSError, ValueError):342
if h is not None:343
os.close(h)344
h = None345
if h is None:346
home = '/var/tmp'347
h = directory(home, readable=True)348
name = 'chj-sm-%d' % uid349
else:350
name = 'ch-j-sm'351
try:352
return subdir(h, name, uid), os.path.join(home, name), uid353
finally:354
os.close(h)357
def operation_id(value):358
if not re.fullmatch('[a-f0-9]{32}', str(value)):359
fail('FILE_INVALID_RECOVERY_ID')360
return value363
def names(value):364
value = operation_id(value)365
return '.' + value + '.tmp', '.' + value + '.json'368
def 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)381
def 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 record393
def 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 result429
def direct_save(d, meta):430
euid = os.geteuid()431
if euid == 0:432
return True433
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 False440
def backup_copy(ident, data, meta):441
"""Optional rollback copy of the original in the effective account's private442
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)461
def finalize(a, d, uid):462
global REPLACED463
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 = False476
old = None477
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 = True487
try:488
# Write fully before applying security metadata (capabilities489
# 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 replacement499
# 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 None505
os.replace(destination, name, src_dir_fd=td, dst_dir_fd=td)506
REPLACED = True507
created = False508
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'] = backup523
return result524
finally:525
os.close(dest)526
finally:527
if created:528
try:529
os.unlink(destination, dir_fd=td)530
except OSError:531
pass532
if old is not None:533
os.close(old)534
os.close(td)535
finally:536
os.close(source)539
def 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
pass549
nxt = os.open(part, os.O_PATH | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)550
os.close(fd)551
fd = nxt552
trusted(fd, os.geteuid() == 0)553
finally:554
os.close(fd)557
def target_state(td, name):558
try:559
s = os.stat(name, dir_fd=td, follow_symlinks=False)560
except FileNotFoundError:561
return None562
if not stat.S_ISREG(s.st_mode):563
fail('FILE_UNSAFE_TYPE_OR_LINK')564
return s567
def already_published(td, name, expected, size):568
s = target_state(td, name)569
if s is None or s.st_size != size:570
return False571
fd = regular(td, name, single=False)572
try:573
return stream_hash(fd)[0] == expected574
finally:575
os.close(fd)578
def 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
return583
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
raise590
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
return594
os.unlink(temp, dir_fd=td)597
def publish(a):598
"""Move a verified, completely transferred file into its destination."""599
global REPLACED600
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 = None610
temp = None611
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 = None625
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 so639
# 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 = True661
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 = True671
except OSError:672
pass673
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
pass683
for fd in (src, old, sd, td):684
if fd is not None:685
os.close(fd)688
def stage_out(a):689
"""Privileged download: copy a protected file into the account's private690
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
pass710
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_gid720
except KeyError:721
gid = -1722
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
pass730
raise731
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)741
def 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'] += 1746
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)758
def 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
continue769
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)781
def 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 mode788
def file_operation(op, a):789
privileged = os.geteuid() == 0790
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
pass855
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 None886
def 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')913
def run(a):914
op = a['operation']915
result = file_operation(op, a)916
if result is not None:917
return result918
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 result950
if op != 'finalize':951
fail('FILE_INVALID_OPERATION')952
return finalize(a, d, uid)953
finally:954
os.close(d)957
def 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()))967
ERRNO_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'972
}975
def respond(code):976
print(json.dumps(dict(ok=False, code='FILE_RESULT_UNKNOWN' if REPLACED else code)))979
try:980
if sys.platform != 'linux':981
fail('FILE_LINUX_REQUIRED')982
print(json.dumps(dict(ok=True, value=run(read_request()))))983
except BlockingIOError:984
respond('FILE_CONFLICT')985
except PermissionError:986
respond('FILE_PERMISSION_DENIED')987
except FileNotFoundError:988
respond('FILE_NOT_FOUND')989
except OSError as e:990
respond(ERRNO_CODES.get(e.errno, 'FILE_REMOTE_IO_FAILED'))991
except (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-256
434e716eefdeda79a2c64e672b11b27cc8bdcdfbb0b553de6545f012db1b647fCH-J Proprietary Software License 1.14 · ソースは CH-J Proprietary Software License 1.14 に基づいて提供されています。公開されていても、ライセンス条項が変わったり、追加の権利が付与されたりすることはありません。
アーカイブの SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a