diff --git a/docs/NIX.md b/docs/NIX.md index b0cd1e3..fe6e9e4 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -217,6 +217,9 @@ Darling namespace; `darling-lol --stop` can still stop it. copies first and removes the old prefix only after the copy completes. If the path already holds a Darling prefix, it switches to it and moves nothing. The path must not contain commas or colons (overlayfs uses them as separators). + Across filesystems the target must support `user.*` extended attributes (ext4, + btrfs, xfs; not NTFS or exFAT), which is checked before copying and again on + the finished copy. The new path is saved before the old prefix is deleted. The next launch sets up the runtime in the new prefix. - Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved configuration, so clear stale overrides before switching installations. diff --git a/scripts/nix-prefix.py b/scripts/nix-prefix.py index 20f2eca..102b0d9 100644 --- a/scripts/nix-prefix.py +++ b/scripts/nix-prefix.py @@ -12,6 +12,7 @@ from pathlib import Path import shutil import subprocess import sys +import tempfile ROOT = Path(__file__).resolve().parents[1] @@ -25,28 +26,77 @@ def is_prefix(path): return (path / 'Volumes/SystemRoot').is_dir() +XATTR = 'user.darling-lol-prefix-check' + + +def user_xattrs(path): + try: + return {name: os.getxattr(path, name, follow_symlinks=False) + for name in os.listxattr(path, follow_symlinks=False) if name.startswith('user.')} + except OSError as error: + if error.errno in (errno.ENOTSUP, errno.ENOENT): + return {} + raise + + +def check_xattrs(folder): + """Darling keeps state in user.* xattrs; cp -a ignores a target that drops them.""" + probe = folder / '.xattr-check' + probe.touch() + try: + os.setxattr(probe, XATTR, b'1') + if os.getxattr(probe, XATTR) != b'1': + raise OSError(errno.ENOTSUP, 'xattr changed') + except OSError as error: + raise SystemExit(f'{folder.parent} does not support user extended attributes ({error.strerror}), ' + 'which the prefix needs. Choose a drive with a Linux filesystem ' + '(ext4, btrfs, xfs); nothing was moved.') + finally: + probe.unlink() + + +def verify_copy(old, new): + """The copy must match the original: names, types and user.* xattrs.""" + for folder, folders, files in os.walk(old): + target = new / Path(folder).relative_to(old) + for name in [*folders, *files]: + source = Path(folder) / name + copy = target / name + if not copy.is_symlink() and not copy.exists() or user_xattrs(source) != user_xattrs(copy): + raise SystemExit(f'The copy of {source} differs from the original; the old prefix was kept.') + if user_xattrs(folder) != user_xattrs(target): + raise SystemExit(f'The copy of {folder} differs from the original; the old prefix was kept.') + + def move(old, new): + """Put the prefix at new. Returns old if it still has to be removed, else None.""" new.parent.mkdir(parents=True, exist_ok=True) if new.is_dir(): new.rmdir() try: os.rename(old, new) - return + return None except OSError as error: if error.errno != errno.EXDEV: raise - partial = new.with_name(new.name + '.partial') - if partial.exists(): - shutil.rmtree(partial) - print(f'Copying {old} to {new}; this can take several minutes.', flush=True) + # A directory made for this move: never touch anything that was already there. + staging = Path(tempfile.mkdtemp(prefix=new.name + '.partial-', dir=new.parent)) try: - subprocess.run(['cp', '-a', '--', str(old), str(partial)], check=True) - except BaseException: - shutil.rmtree(partial, ignore_errors=True) - raise - os.rename(partial, new) - print(f'Removing {old}.', flush=True) - shutil.rmtree(old) + check_xattrs(staging) + print(f'Copying {old} to {new}; this can take several minutes.', flush=True) + subprocess.run(['cp', '-a', '--', str(old), str(staging / 'prefix')], check=True) + verify_copy(old, staging / 'prefix') + os.rename(staging / 'prefix', new) + finally: + shutil.rmtree(staging, ignore_errors=True) + return old + + +def remove(path): + try: + shutil.rmtree(path) + except OSError as error: + print(f'Could not remove {path} ({error}); delete it by hand to free the space.', file=sys.stderr) def main(): @@ -58,20 +108,29 @@ def main(): # Darling passes the prefix to overlayfs, which splits its options on these. if any(character in str(new) for character in ',:\n'): raise SystemExit('The prefix path must not contain commas, colons or newlines.') + # Darling's overlay work directory; it creates a new one next to the prefix. + workdir = old.with_name(old.name + '.workdir') + leftovers = [] if new != old: if new.is_relative_to(old) or old.is_relative_to(new): raise SystemExit(f'{new} and {old} must not contain each other.') + if new.is_relative_to(workdir) or workdir.is_relative_to(new): + raise SystemExit(f'{new} must not be inside {workdir}, which is removed after the move.') if has_files(new): if not is_prefix(new): raise SystemExit(f'{new} is not empty and is not a Darling prefix; ' 'choose a new or empty directory.') print(f'Using the existing prefix in {new}; {old} is left as it is.') elif has_files(old): - move(old, new) - # Darling's overlay work directory; it creates a new one next to the prefix. - shutil.rmtree(old.with_name(old.name + '.workdir'), ignore_errors=True) + print(f'Moving the prefix {old} to {new}.', flush=True) + if move(old, new): + leftovers.append(old) + leftovers.append(workdir) + # Save the new path before deleting anything, so a failed delete leaves a working install. subprocess.run([sys.executable, str(ROOT / 'scripts/configure.py'), '--prefix', str(new)], cwd=ROOT, stdout=subprocess.DEVNULL, check=True) + for path in leftovers: + remove(path) print(f'The prefix is now {new}. The next darling-lol launch sets up the runtime there if needed.') return 0 diff --git a/tests/test_nix_prefix.py b/tests/test_nix_prefix.py index daf2493..0b44330 100644 --- a/tests/test_nix_prefix.py +++ b/tests/test_nix_prefix.py @@ -64,12 +64,103 @@ class PrefixTests(unittest.TestCase): new = self.folder / 'bulk/prefix' with patch.object(prefix.os, 'rename', cross_device): - prefix.move(self.old, new) + leftover = prefix.move(self.old, new) # A failed rename of the prefix, then the rename of the finished copy. self.assertEqual(calls, ['prefix', 'prefix']) self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data') - self.assertFalse(self.old.exists()) - self.assertFalse((self.folder / 'bulk/prefix.partial').exists()) + # The caller removes the old prefix once the new path is saved. + self.assertEqual(leftover, self.old) + self.assertTrue(self.old.exists()) + self.assertEqual(list((self.folder / 'bulk').iterdir()), [new]) + + def load(self): + spec = importlib.util.spec_from_file_location('nix_prefix_unsafe', self.workspace / 'scripts/nix-prefix.py') + prefix = importlib.util.module_from_spec(spec) + spec.loader.exec_module(prefix) + return prefix + + def cross_device(self, prefix): + rename = os.rename + first = [] + + def rename_across(source, target): + if not first: + first.append(1) + raise OSError(errno.EXDEV, 'cross-device link') + rename(source, target) + return patch.object(prefix.os, 'rename', rename_across) + + def test_copy_leaves_an_existing_partial_directory_alone(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + unrelated = self.folder / 'bulk/prefix.partial' + unrelated.mkdir(parents=True) + (unrelated / 'keep').write_text('mine') + with self.cross_device(prefix): + prefix.move(self.old, new) + self.assertEqual((unrelated / 'keep').read_text(), 'mine') + self.assertEqual(sorted(path.name for path in (self.folder / 'bulk').iterdir()), + ['prefix', 'prefix.partial']) + + def test_failed_copy_keeps_the_old_prefix_and_removes_only_its_staging(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + new.parent.mkdir() + error = subprocess.CalledProcessError(1, 'cp') + with self.cross_device(prefix), patch.object(prefix.subprocess, 'run', side_effect=error): + with self.assertRaises(subprocess.CalledProcessError): + prefix.move(self.old, new) + self.assertEqual((self.old / 'Applications/League of Legends.app/game').read_text(), 'game data') + self.assertEqual(list(new.parent.iterdir()), []) + + def test_copy_refused_when_the_target_drops_user_xattrs(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + with self.cross_device(prefix), patch.object(prefix.os, 'setxattr', + side_effect=OSError(errno.ENOTSUP, 'not supported')): + with self.assertRaises(SystemExit) as raised: + prefix.move(self.old, new) + self.assertIn('extended attributes', str(raised.exception)) + self.assertTrue(self.old.exists()) + self.assertEqual(list(new.parent.iterdir()), []) + + def test_copy_that_lost_xattrs_is_refused(self): + prefix = self.load() + game = self.old / 'Applications/League of Legends.app/game' + try: + os.setxattr(game, 'user.test', b'1') + except OSError: + self.skipTest('the temporary directory has no user xattrs') + new = self.folder / 'bulk/prefix' + real = prefix.user_xattrs + stripped = lambda path: {} if Path(path).name == 'game' and Path(path).is_relative_to(new.parent) else real(path) + with self.cross_device(prefix), patch.object(prefix, 'user_xattrs', stripped): + with self.assertRaises(SystemExit) as raised: + prefix.move(self.old, new) + self.assertIn('old prefix was kept', str(raised.exception)) + self.assertTrue(game.exists()) + self.assertEqual(list(new.parent.iterdir()), []) + + def test_failed_delete_of_the_old_prefix_keeps_the_new_one_saved(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + with patch.dict(os.environ, {'DPREFIX': str(self.old)}), \ + patch.object(sys, 'argv', ['nix-prefix.py', str(new)]), \ + patch.object(prefix, 'ROOT', self.workspace), \ + patch.object(prefix, 'move', lambda old, target: (shutil.copytree(old, target), old)[1]), \ + patch.object(prefix.shutil, 'rmtree', side_effect=OSError('busy')): + self.assertEqual(prefix.main(), 0) + self.assertEqual(self.saved_prefix(), str(new)) + self.assertTrue((new / 'Applications/League of Legends.app/game').exists()) + self.assertTrue(self.old.exists()) + + def test_refuses_a_destination_inside_the_overlay_work_directory(self): + for new in (self.folder / 'home/.darling.workdir/games', self.folder / 'home/.darling.workdir'): + result = self.set_prefix(new) + self.assertNotEqual(result.returncode, 0, new) + self.assertIn('work', result.stderr) + self.assertTrue((self.old / 'Applications/League of Legends.app/game').exists()) + self.assertFalse((self.workspace / 'build/config.json').exists()) def test_switches_to_an_existing_prefix_without_moving(self): other = self.folder / 'other'