From ac184576f62142d8b7866d4f88c3f1b6b455bfe1 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 10:20:52 +0100 Subject: [PATCH] Document bundle installation and normalize release directory permissions --- scripts/installer/install.py | 33 ++++++++++++++++++++-------- scripts/tests/test_portal_install.py | 10 +++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/scripts/installer/install.py b/scripts/installer/install.py index 40342cb..ab8fb25 100644 --- a/scripts/installer/install.py +++ b/scripts/installer/install.py @@ -50,8 +50,23 @@ def sync_dir(path): os.close(fd) +def ensure_directory(path, mode=0o755): + missing = [] + cursor = path + while not cursor.exists(): + if cursor.is_symlink(): + fail(f'Refusing dangling directory symlink: {cursor}') + missing.append(cursor) + cursor = cursor.parent + + for directory in reversed(missing): + directory.mkdir(mode=mode) + directory.chmod(mode) + sync_dir(directory.parent) + + def atomic_bytes(path, data, mode=0o644): - path.parent.mkdir(parents=True, exist_ok=True) + ensure_directory(path.parent) fd, temporary = tempfile.mkstemp(prefix='.ferese-', dir=path.parent) try: with os.fdopen(fd, 'wb') as stream: @@ -68,7 +83,7 @@ def atomic_bytes(path, data, mode=0o644): def atomic_copy(path, source, mode): - path.parent.mkdir(parents=True, exist_ok=True) + ensure_directory(path.parent) fd, temporary = tempfile.mkstemp(prefix='.ferese-', dir=path.parent) try: with os.fdopen(fd, 'wb') as output: @@ -150,10 +165,10 @@ def bundle(args): if output.exists() or output.is_symlink(): fail(f'Bundle already exists: {output}') - output.parent.mkdir(parents=True, exist_ok=True) + ensure_directory(output.parent) with tempfile.TemporaryDirectory(prefix='.bundle-', dir=output.parent) as temporary: staging = Path(temporary) / 'release' - staging.mkdir() + ensure_directory(staging) files = [] for rule in INVENTORY['files']: sources = sorted(source.glob(rule['source'])) @@ -210,7 +225,7 @@ class Installation: @contextlib.contextmanager def lock(self): - self.base.mkdir(parents=True, exist_ok=True) + ensure_directory(self.base) lock_path = self.path(PREFIX + '/.install.lock') fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) try: @@ -339,7 +354,7 @@ class Installation: def write_state(self, absolute, state, source=None): path = self.path(absolute) - path.parent.mkdir(parents=True, exist_ok=True) + ensure_directory(path.parent) if state['kind'] == 'absent': path.unlink(missing_ok=True) sync_dir(path.parent) @@ -359,7 +374,7 @@ class Installation: def archive(self, journal): history = self.path(PREFIX + '/transactions') - history.mkdir(mode=0o700, exist_ok=True) + ensure_directory(history, 0o700) os.rename(self.transaction, history / journal['id']) sync_dir(history) sync_dir(self.base) @@ -433,7 +448,7 @@ class Installation: if releases.is_symlink(): fail(f'Refusing symlinked release directory: {releases}') - releases.mkdir(parents=True, exist_ok=True) + ensure_directory(releases) destination = releases / manifest['release'] if os.path.lexists(destination): if self.manifest(manifest['release']) != manifest: @@ -443,7 +458,7 @@ class Installation: with tempfile.TemporaryDirectory(prefix='.stage-', dir=releases) as temporary: staging = Path(temporary) / 'release' - staging.mkdir() + ensure_directory(staging) for item in manifest['files']: atomic_copy(staging / item['target'], origin / item['target'], int(item['mode'], 8)) diff --git a/scripts/tests/test_portal_install.py b/scripts/tests/test_portal_install.py index 0dabf39..189c899 100644 --- a/scripts/tests/test_portal_install.py +++ b/scripts/tests/test_portal_install.py @@ -224,6 +224,16 @@ m.main() self.assertIn('in progress', result.stderr) self.assertFalse((self.base / 'current').exists()) + def test_restrictive_umask_does_not_make_installed_release_private(self): + result = subprocess.run([sys.executable, '-c', + f"import os, runpy; os.umask(0o077); runpy.run_path({str(HELPER)!r}, run_name='__main__')", + 'install', str(self.bundles[0]), '--root', str(self.root)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for relative in ('usr', 'usr/local', PREFIX, PREFIX + '/releases', PREFIX + '/releases/first', + PREFIX + '/releases/first/wallpapers', 'usr/share/wayland-sessions'): + self.assertEqual((self.root / relative).stat().st_mode & 0o777, 0o755, relative) + def test_dry_run_does_not_write(self): before = self.snapshot() self.run_command('install', self.bundles[0], '--dry-run')