mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-27 19:54:38 +00:00
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]>
192 lines
8.9 KiB
Python
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()
|