257 lines
14 KiB
Python
257 lines
14 KiB
Python
"""Reviewed, rename-only separation of manager metadata from runtime data.
|
|
|
|
Called by access retirement only after every inventoried old writer exited.
|
|
Private trees retain every inode/UID/GID; modes are preserved except explicitly
|
|
reviewed identity-directory hardening to 0700. Runtime trees are never moved.
|
|
An interrupted transition remains fenced. This module never copies data, chowns
|
|
existing data, generates identities, thaws writers, or initializes storage.
|
|
"""
|
|
import ctypes
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
|
|
import c01_access_policy as policy_module
|
|
|
|
require=policy_module.require
|
|
LIBC=ctypes.CDLL(None,use_errno=True)
|
|
LIBC.renameat2.argtypes=[ctypes.c_int,ctypes.c_char_p,ctypes.c_int,ctypes.c_char_p,ctypes.c_uint]
|
|
LIBC.renameat2.restype=ctypes.c_int
|
|
|
|
|
|
def identity(value):return policy_module.identity(value)
|
|
|
|
|
|
def validate(plan, launch, runtime):
|
|
require(type(plan) is dict and set(plan)=={'version','source_root','source_identity','target_root',
|
|
'target_identity','private_parent','entries','quarantine_parents'},'layout exact fields')
|
|
require(type(plan['version']) is int and plan['version']==1,'layout version')
|
|
require(plan['source_root']==runtime['runtime_data_dir'] and plan['target_root']==launch['manager_backing'],
|
|
'layout must separate the appointed manager/runtime roots')
|
|
source=policy_module.canonical(plan['source_root']);target=policy_module.canonical(plan['target_root'])
|
|
require(source!=target and not source.is_relative_to(target) and not target.is_relative_to(source),
|
|
'layout requires an independently protected target alias')
|
|
parent=plan['private_parent']
|
|
require(type(parent) is dict and set(parent)=={'path','identity'} and
|
|
policy_module.canonical(parent['path']).parent==source,'private parent binding')
|
|
for value in (plan['source_identity'],plan['target_identity'],parent['identity']):
|
|
require(type(value) is list and len(value)==5 and all(type(n) is int and n>=0 for n in value),
|
|
'layout identity encoding')
|
|
require(plan['source_identity'][0]==plan['target_identity'][0]==parent['identity'][0] and
|
|
plan['source_identity'][2:4]==[runtime['runtime_uid'],runtime['runtime_gid']] and
|
|
plan['target_identity'][2:]==[runtime['runtime_uid'],runtime['runtime_gid'],0o700] and
|
|
parent['identity'][2:]==[0,0,0o700], 'same-filesystem private custody required')
|
|
require(type(plan['entries']) is list and 1<=len(plan['entries'])<=1024,'complete bounded root inventory')
|
|
names=set();private=set()
|
|
for entry in plan['entries']:
|
|
require(type(entry) is dict and set(entry)=={'name','disposition','identity','mode_after','tree_metadata_sha256'},'layout entry fields')
|
|
name=entry['name']
|
|
require(type(name) is str and name not in ('','.','..',Path(parent['path']).name) and
|
|
'/' not in name and '\0' not in name and len(os.fsencode(name))<=255 and name not in names,
|
|
'layout entry name/alias')
|
|
names.add(name)
|
|
require(entry['disposition'] in ('private','runtime'),'unclassified data blocks migration')
|
|
value=entry['identity']
|
|
require(type(value) is list and len(value)==5 and all(type(n) is int and n>=0 for n in value), 'entry identity')
|
|
require(type(entry['mode_after']) is int and (entry['mode_after']==value[4] or
|
|
(name=='identity' and entry['disposition']=='private' and value[4] in (0o750,0o755) and
|
|
entry['mode_after']==0o700)), 'only explicit identity-directory mode hardening is permitted')
|
|
if name=='identity':
|
|
require(entry['disposition']=='private' and entry['mode_after']==0o700 and
|
|
value[2:4]==[runtime['runtime_uid'],runtime['runtime_gid']],
|
|
'identity directory must meet the existing private-owner contract')
|
|
if entry['disposition']=='private':
|
|
private.add(name)
|
|
require(policy_module.hex_digest(entry['tree_metadata_sha256']),'private metadata inventory required')
|
|
else:require(entry['tree_metadata_sha256'] is None,'runtime contents must not be copied/reinventoried')
|
|
require({'identity','federation'}<=private,'existing identity and history must move intact')
|
|
require(type(plan['quarantine_parents']) is list and len(plan['quarantine_parents'])<=16 and
|
|
len(set(plan['quarantine_parents']))==len(plan['quarantine_parents']),'quarantine parent inventory')
|
|
for path in plan['quarantine_parents']:policy_module.canonical(path)
|
|
|
|
|
|
def readable(path):
|
|
pin=policy_module.open_path(path,True)
|
|
try:return os.open('.',os.O_RDONLY|os.O_DIRECTORY|os.O_CLOEXEC,dir_fd=pin)
|
|
finally:os.close(pin)
|
|
|
|
|
|
def private_metadata(path, normalized_root_mode=None):
|
|
"""Bounded metadata inventory, without reading wallet/cache/private-key bytes.
|
|
|
|
Hard links are permitted only when every link is inside this moved tree.
|
|
Symlinks/sockets/devices require explicit resolution, never guessed rewrites.
|
|
"""
|
|
path=Path(path)
|
|
for line in Path('/proc/self/mountinfo').read_text().splitlines():
|
|
fields=line.split()
|
|
target=Path(re.sub(r'\\([0-7]{3})',lambda m:chr(int(m[1],8)),fields[4]))
|
|
require(not target.is_relative_to(path), 'private nested mount requires explicit disposition')
|
|
records=[];links={};expected={};device=path.lstat().st_dev
|
|
def visit(parent,name,relative,depth):
|
|
require(depth<=64 and len(records)<100000,'private metadata inventory bound')
|
|
m=os.stat(name,dir_fd=parent,follow_symlinks=False)
|
|
require(m.st_dev==device,'private tree crosses filesystem boundary')
|
|
require(stat.S_ISDIR(m.st_mode) or stat.S_ISREG(m.st_mode),'unresolved private alias/object')
|
|
recorded=identity(m)
|
|
if relative=='' and normalized_root_mode is not None:recorded[4]=normalized_root_mode
|
|
records.append([relative,recorded,m.st_size,m.st_mtime_ns,stat.S_IFMT(m.st_mode),m.st_nlink])
|
|
if stat.S_ISREG(m.st_mode):
|
|
key=(m.st_dev,m.st_ino);links[key]=links.get(key,0)+1;expected[key]=m.st_nlink
|
|
return
|
|
fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=parent)
|
|
try:
|
|
require(identity(os.fstat(fd))==identity(m),'private directory changed')
|
|
for child in sorted(os.listdir(fd)):visit(fd,child,relative+'/'+child,depth+1)
|
|
after=os.fstat(fd)
|
|
require((identity(after),after.st_size,after.st_mtime_ns)==(identity(m),m.st_size,m.st_mtime_ns),
|
|
'private metadata changed during inventory')
|
|
finally:os.close(fd)
|
|
parent=readable(Path(path).parent)
|
|
try:visit(parent,Path(path).name,'',0)
|
|
finally:os.close(parent)
|
|
require(links==expected,'private file has an unaccounted hard-link alias')
|
|
return hashlib.sha256(json.dumps(records,separators=(',',':'),ensure_ascii=True).encode()).hexdigest()
|
|
|
|
|
|
def roots(plan):
|
|
parent=Path(plan['private_parent']['path'])
|
|
m=parent.lstat()
|
|
require(stat.S_ISDIR(m.st_mode) and identity(m)==plan['private_parent']['identity'], 'private custody parent changed')
|
|
target=Path(plan['target_root'])
|
|
policy_module.protected(target.parent,private=True,directory=True)
|
|
require(identity(target.parent.stat())==identity(m), 'private target alias differs from original custody inode')
|
|
source_fd=readable(plan['source_root'])
|
|
try:
|
|
# renameat2 requires the same VFS mount, not merely st_dev equality.
|
|
# Authenticate against the protected alias, but perform the move using
|
|
# the explicitly appointed original directory on the source mount.
|
|
alias_fd=readable(target)
|
|
try:
|
|
alias_identity=identity(os.fstat(alias_fd));alias_flags=os.fstatvfs(alias_fd).f_flag
|
|
parent_fd=os.open(parent.name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=source_fd)
|
|
try:
|
|
require(identity(os.fstat(parent_fd))==plan['private_parent']['identity'], 'original private parent replaced')
|
|
target_fd=os.open(target.name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=parent_fd)
|
|
finally:os.close(parent_fd)
|
|
finally:os.close(alias_fd)
|
|
try:
|
|
require(alias_identity==identity(os.fstat(target_fd)), 'private alias/original mismatch')
|
|
require(identity(os.fstat(source_fd))==plan['source_identity'] and
|
|
identity(os.fstat(target_fd))==plan['target_identity'], 'layout root identity changed')
|
|
flags=os.ST_RDONLY|os.ST_NOSUID|os.ST_NODEV|os.ST_NOEXEC
|
|
original=os.fstatvfs(source_fd).f_flag & flags
|
|
require(os.fstatvfs(target_fd).f_flag & original == original and alias_flags & original == original,
|
|
'layout weakens original mount protections')
|
|
return source_fd,target_fd
|
|
except BaseException:os.close(target_fd);raise
|
|
except BaseException:os.close(source_fd);raise
|
|
|
|
|
|
def preflight(policy):
|
|
plan=policy.value['migration_layout'];validate(plan,policy.launch,policy.runtime)
|
|
source,target=roots(plan)
|
|
try:
|
|
require(set(os.listdir(source))=={e['name'] for e in plan['entries']}|{Path(plan['private_parent']['path']).name},
|
|
'unclassified/new root entry blocks layout migration')
|
|
require(not os.listdir(target),'existing manager target cannot be overwritten')
|
|
for entry in plan['entries']:
|
|
m=os.stat(entry['name'],dir_fd=source,follow_symlinks=False)
|
|
require(identity(m)==entry['identity'],'layout source identity changed')
|
|
if entry['disposition']=='private':
|
|
require(entry['name']!='identity' or stat.S_ISDIR(m.st_mode),'identity must be a directory')
|
|
require(private_metadata(Path(plan['source_root'])/entry['name'])==entry['tree_metadata_sha256'],
|
|
'private source metadata changed')
|
|
finally:os.close(source);os.close(target)
|
|
|
|
|
|
def apply_retired(policy, resume=False):
|
|
if not resume:preflight(policy)
|
|
plan=policy.value['migration_layout'];source,target=roots(plan)
|
|
try:
|
|
moved=relocate_entries(plan,source,target,resume)
|
|
verify_live(policy)
|
|
return {'source_identity':plan['source_identity'],'target_identity':plan['target_identity'],'moved':moved}
|
|
finally:os.close(source);os.close(target)
|
|
|
|
|
|
def relocate_entries(plan,source,target,resume):
|
|
"""Filesystem primitive; caller owns validated roots and writer retirement."""
|
|
moved=[]
|
|
if resume:
|
|
source_names=set(os.listdir(source));target_names=set(os.listdir(target))
|
|
private={e['name'] for e in plan['entries'] if e['disposition']=='private'}
|
|
runtime={e['name'] for e in plan['entries'] if e['disposition']=='runtime'}
|
|
parent=Path(plan['private_parent']['path']).name
|
|
require(target_names<=private and source_names|target_names==private|runtime|{parent} and
|
|
not (source_names & target_names), 'ambiguous partial layout requires explicit disposition')
|
|
for entry in plan['entries']:
|
|
if entry['disposition']!='private':continue
|
|
name=os.fsencode(entry['name'])
|
|
if not resume or entry['name'] in source_names:
|
|
require(identity(os.stat(entry['name'],dir_fd=source,follow_symlinks=False))==entry['identity'] and
|
|
private_metadata(Path(plan['source_root'])/entry['name'])==entry['tree_metadata_sha256'],
|
|
'retired source identity/metadata changed')
|
|
if LIBC.renameat2(source,name,target,name,1):
|
|
error=ctypes.get_errno();raise OSError(error,os.strerror(error))
|
|
os.fsync(source);os.fsync(target)
|
|
current=os.stat(entry['name'],dir_fd=target,follow_symlinks=False)
|
|
expected=list(entry['identity']);expected[4]=entry['mode_after']
|
|
require(identity(current) in (entry['identity'],expected) and
|
|
private_metadata(Path(plan['target_root'])/entry['name'],entry['identity'][4])==entry['tree_metadata_sha256'],
|
|
'layout preservation proof differs')
|
|
if stat.S_IMODE(current.st_mode)!=entry['mode_after']:
|
|
require(entry['name']=='identity' and stat.S_ISDIR(current.st_mode),'unapproved metadata mode change')
|
|
fd=os.open(entry['name'],os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=target)
|
|
try:os.fchmod(fd,entry['mode_after']);os.fsync(fd)
|
|
finally:os.close(fd)
|
|
os.fsync(target)
|
|
require(identity(os.stat(entry['name'],dir_fd=target,follow_symlinks=False))==expected,
|
|
'identity hardening postimage differs')
|
|
moved.append({'name':entry['name'],'identity':entry['identity'],'mode_after':entry['mode_after'],
|
|
'tree_metadata_sha256':entry['tree_metadata_sha256']})
|
|
return moved
|
|
|
|
|
|
def verify_live(policy):
|
|
plan=policy.value['migration_layout'];source,target=roots(plan)
|
|
try:
|
|
for entry in plan['entries']:
|
|
if entry['disposition']=='private':
|
|
require(not os.path.lexists(Path(plan['source_root'])/entry['name']), 'legacy private locator reappeared')
|
|
# Runtime data may legitimately evolve after activation. Its root
|
|
# preservation is separately checked by the existing access policy.
|
|
finally:os.close(source);os.close(target)
|
|
|
|
|
|
def custody(policy,index,item):
|
|
if 'custody' not in item:return policy_module.STATE/'quarantine'/str(index)
|
|
require(policy.value['version']==3 and item['disposition']=='quarantine-known-copy','unappointed custody override')
|
|
target=policy_module.canonical(item['custody'])
|
|
require(str(target.parent) in policy.value['migration_layout']['quarantine_parents'] and target.name==str(index),
|
|
'quarantine destination differs from reviewed per-filesystem custody')
|
|
return target
|
|
|
|
|
|
def custody_directory(policy,index,item):
|
|
target=custody(policy,index,item)
|
|
policy_module.protected(target.parent,private=True,directory=True)
|
|
if 'custody' in item:return readable(target.parent)
|
|
plan=policy.value['migration_layout']
|
|
# The default access-state alias is deliberately provisioned on the data
|
|
# filesystem. Compare both capabilities before using the original mount.
|
|
source,manager=roots(plan)
|
|
os.close(source);os.close(manager)
|
|
physical=Path(plan['private_parent']['path'])/'access/quarantine'
|
|
alias=readable(target.parent)
|
|
try:
|
|
fd=readable(physical)
|
|
try:
|
|
require(identity(os.fstat(fd))==identity(os.fstat(alias)), 'quarantine alias/original mismatch')
|
|
return fd
|
|
except BaseException:os.close(fd);raise
|
|
finally:os.close(alias)
|