Files
darling-lol/tests/test_nix_prefix.py
lucannez64andClaude Sonnet 5.5 20d8320bf6 Make --set-prefix safe against data loss
Stage a cross-filesystem copy in a private directory instead of deleting a
pre-existing PATH.partial, reject destinations inside the overlay work
directory, save the new path before deleting the old prefix, and verify that
the target keeps user.* xattrs before and after copying. Add tests.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 19:04:30 +02:00

192 lines
8.9 KiB
Python

"""darling-lol --set-prefix on temporary directories; no Darling or namespace."""
import errno
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
class PrefixTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.folder = Path(self.tmp.name)
self.workspace = self.folder / 'workspace'
(self.workspace / 'scripts').mkdir(parents=True)
(self.workspace / 'tools').mkdir()
for name in ('nix-prefix.py', 'configure.py', 'common.py'):
shutil.copy(ROOT / 'scripts' / name, self.workspace / 'scripts' / name)
self.old = self.folder / 'home/.darling'
(self.old / 'Volumes/SystemRoot').mkdir(parents=True)
(self.old / 'Applications/League of Legends.app').mkdir(parents=True)
(self.old / 'Applications/League of Legends.app/game').write_text('game data')
(self.folder / 'home/.darling.workdir/work').mkdir(parents=True)
def set_prefix(self, new):
return subprocess.run([sys.executable, str(self.workspace / 'scripts/nix-prefix.py'), str(new)],
env=dict(os.environ, DPREFIX=str(self.old), HOME=str(self.folder / 'home'),
PYTHONDONTWRITEBYTECODE='1'),
text=True, capture_output=True, timeout=30)
def saved_prefix(self):
return json.loads((self.workspace / 'build/config.json').read_text())['prefix']
def test_moves_the_prefix_and_saves_it(self):
new = self.folder / 'bulk/Games/League Of Legends'
result = self.set_prefix(new)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data')
self.assertFalse(self.old.exists())
self.assertFalse((self.folder / 'home/.darling.workdir').exists())
self.assertEqual(self.saved_prefix(), str(new))
def test_copies_across_filesystems_and_removes_the_old_copy_last(self):
spec = importlib.util.spec_from_file_location('nix_prefix', self.workspace / 'scripts/nix-prefix.py')
prefix = importlib.util.module_from_spec(spec)
spec.loader.exec_module(prefix)
rename = os.rename
calls = []
def cross_device(source, target):
calls.append(Path(target).name)
if len(calls) == 1:
raise OSError(errno.EXDEV, 'cross-device link')
self.assertTrue(self.old.exists(), 'old prefix removed before the copy finished')
rename(source, target)
new = self.folder / 'bulk/prefix'
with patch.object(prefix.os, 'rename', cross_device):
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')
# 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'
(other / 'Volumes/SystemRoot').mkdir(parents=True)
result = self.set_prefix(other)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.old / 'Applications/League of Legends.app/game').exists())
self.assertEqual(self.saved_prefix(), str(other))
def test_refuses_a_directory_with_other_files(self):
games = self.folder / 'Games'
games.mkdir()
(games / 'other-game').mkdir()
result = self.set_prefix(games)
self.assertNotEqual(result.returncode, 0)
self.assertIn('not a Darling prefix', result.stderr)
self.assertTrue(self.old.exists())
self.assertFalse((self.workspace / 'build/config.json').exists())
def test_refuses_overlay_separators_and_nested_paths(self):
for new in (self.folder / 'a,b', self.folder / 'a:b', self.old / 'inner'):
result = self.set_prefix(new)
self.assertNotEqual(result.returncode, 0, new)
self.assertTrue(self.old.exists())
if __name__ == '__main__':
unittest.main()