"""Install the two query skills from a verified package; default is a read-only plan. Only checksummed skill files are managed. Credentials, employee state, databases, gateway configuration and services are never installed or modified here. """ import argparse import hashlib import json import os from pathlib import Path import shutil import tempfile NAMES = ('ksf-workshop-runtime', 'ksf-voc-library') def digest(path): with path.open('rb') as stream: result = hashlib.sha256() for block in iter(lambda: stream.read(1024 * 1024), b''): result.update(block) return result.hexdigest() def plain(path): path = Path(path).absolute() for ancestor in (path, *path.parents): if ancestor.exists() or ancestor.is_symlink(): if ancestor.is_symlink() or ancestor.resolve() != ancestor: raise ValueError('REDIRECTED_PATH') return path def snapshot(folder): if not folder.exists(): return {} result = {} for path in folder.rglob('*'): plain(path) if path.is_file(): result[path.relative_to(folder).as_posix()] = digest(path) return result def contents(folder): plain(folder) entries = {} for line in (folder / 'FILES.sha256').read_text(encoding='utf-8').splitlines(): sha, rel = line.split(' ', 1) parts = Path(rel).parts if len(sha) != 64 or any(ch not in '0123456789abcdef' for ch in sha) or not parts or Path(rel).is_absolute() or '..' in parts or rel in entries or rel in ('FILES.sha256', 'credential-binding.json'): raise ValueError('INVALID_SKILL_MANIFEST') if digest(plain(folder / rel)) != sha: raise ValueError('PACKAGE_HASH_MISMATCH') entries[rel] = sha entries['FILES.sha256'] = digest(folder / 'FILES.sha256') if snapshot(folder) != entries: raise ValueError('UNEXPECTED_SKILL_FILES') return entries def install(package, data_root, apply=False): package, data_root = plain(package), plain(data_root) if package == data_root or package in data_root.parents: raise ValueError('NESTED_PACKAGE_AND_DATA_ROOT') baseline = json.loads((package / 'baselines.json').read_text(encoding='utf-8')) entries, changes, targets = {}, [], {} for name in NAMES: source = package / 'skills' / name target = plain(data_root / 'skills' / name) if package == target or package in target.parents or target in package.parents: raise ValueError('NESTED_PACKAGE_AND_SKILL') wanted = contents(source) entries[name], targets[name] = wanted, target if target.exists() and not target.is_dir(): raise ValueError('TARGET_NOT_DIRECTORY') actual = {rel: digest(plain(target / rel)) for rel in wanted if (target / rel).is_file()} if target.exists() and actual != wanted and actual not in baseline[name]: raise ValueError('UNKNOWN_SKILL_BASELINE:' + name) if not target.exists() and name == 'ksf-workshop-runtime': raise ValueError('WORKSHOP_INSTANCE_NOT_PROVISIONED') for rel, sha in wanted.items(): if actual.get(rel) != sha: changes.append((name, rel, actual.get(rel), sha)) state = data_root / 'ksf-workshop-state' binding = targets['ksf-workshop-runtime'] / 'credential-binding.json' before_state = snapshot(state) before_binding = digest(plain(binding)) if binding.is_file() else None result = dict(ok=True, readOnly=not apply, applied=False, changed=[name + '/' + rel for name, rel, _, _ in changes], filesChecked=sum(len(files) for files in entries.values()), databaseWrites=0, databaseChecked=False, networkRequests=0, credentialBindingPreserved=True, stateDirectoryPreserved=True, gatewayModified=False, discoveryVerified=False, realMessageVerified=False) if not apply or not changes: return result data_root.mkdir(parents=True, exist_ok=True) backup = Path(tempfile.mkdtemp(prefix='ksf-query-skills-backup-', dir=data_root)) os.chmod(backup, 0o700) installed = [] try: for name, rel, before, after in changes: source, target = package / 'skills' / name / rel, targets[name] / rel if (digest(plain(target)) if target.is_file() else None) != before: raise ValueError('CONCURRENT_SKILL_CHANGE') mode = target.stat().st_mode & 0o777 if target.is_file() else 0o600 previous = backup / name / rel if before: previous.parent.mkdir(parents=True, exist_ok=True, mode=0o700) shutil.copy2(target, previous) target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) fd, temporary = tempfile.mkstemp(prefix='.query-skill-', dir=target.parent) temporary = Path(temporary) try: with os.fdopen(fd, 'wb') as stream: stream.write(source.read_bytes()) stream.flush() os.fsync(stream.fileno()) os.chmod(temporary, mode) if digest(temporary) != after: raise ValueError('STAGED_HASH_MISMATCH') if (digest(plain(target)) if target.is_file() else None) != before: raise ValueError('CONCURRENT_SKILL_CHANGE') os.replace(temporary, target) installed.append((target, previous, before, after)) finally: temporary.unlink(missing_ok=True) for name, wanted in entries.items(): if any(digest(plain(targets[name] / rel)) != sha for rel, sha in wanted.items()): raise ValueError('INSTALLED_HASH_MISMATCH') if snapshot(state) != before_state or (digest(plain(binding)) if binding.is_file() else None) != before_binding: raise ValueError('STATE_OR_BINDING_CHANGED') except Exception: for target, previous, before, after in reversed(installed): # Never overwrite a concurrent edit while rolling back our files. if target.is_file() and digest(target) == after: if before: shutil.copy2(previous, target) else: target.unlink() raise result.update(applied=True, backup=str(backup), filesystemVerified=True) return result if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--package', required=True) parser.add_argument('--data-root', default='/opt/data') mode = parser.add_mutually_exclusive_group() mode.add_argument('--check', action='store_true') mode.add_argument('--apply', action='store_true') args = parser.parse_args() try: print(json.dumps(install(args.package, args.data_root, args.apply), ensure_ascii=False, indent=2)) except (ValueError, OSError, KeyError) as error: print(json.dumps(dict(ok=False, error=str(error)), ensure_ascii=False)) raise SystemExit(1)