--- a/scripts/repository-candidate-gate +++ b/scripts/repository-candidate-gate @@ -388,7 +388,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--config", required=True) + parser.add_argument("--config", help="repository gate policy (required for the main process)") parser.add_argument("--candidate", default=".", help="candidate tree (default: working directory)") parser.add_argument("--baseline", help="baseline tree (default: config baseline, the published tree)") parser.add_argument("--worker", nargs="+", help=argparse.SUPPRESS) @@ -397,6 +397,9 @@ kind, root, out, *atoms = args.worker worker_packages(root, out) if kind == "packages" else worker_resolve(root, atoms, out) return 0 + + if not args.config: + parser.error("--config is required for the main gate process") # Portage sources ebuilds as the portage user: the work tree must be traversable. os.umask(0o022) --- a/src/foundry/repository_activation.py +++ b/src/foundry/repository_activation.py @@ -2,6 +2,7 @@ from __future__ import annotations import configparser +import errno import grp import json import os @@ -69,12 +70,29 @@ """Give the generated tree ordinary checkout modes before it is frozen.""" if not data.get("activation"): return + + def clear_authoring_acl(path: Path) -> None: + # An inherited ACL mask can look group-readable in st_mode while its + # owning-group entry denies access after publication chowns to Portage. + # Strip workspace ACLs only on this generated public copy, before freeze. + if not hasattr(os, "removexattr"): + return + for attribute in ("system.posix_acl_access", "system.posix_acl_default"): + try: + os.removexattr(path, attribute) + except OSError as error: + if error.errno not in {getattr(errno, "ENODATA", -1), + getattr(errno, "ENOATTR", -1), errno.ENOTSUP}: + raise + for directory, dirs, files in os.walk(tree, followlinks=False): + clear_authoring_acl(Path(directory)) os.chmod(directory, 0o755) for item in files: path = Path(directory) / item mode = path.lstat().st_mode if not stat.S_ISLNK(mode): + clear_authoring_acl(path) os.chmod(path, 0o755 if mode & stat.S_IXUSR else 0o644) --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -56,7 +56,8 @@ def _config(root: Path) -> FoundryConfig: return FoundryConfig( - path=root / "foundry.toml", state_root=root / "state", portage_config_root=root + path=root / "foundry.toml", state_root=root / "state", portage_config_root=root, + publish_root=root / "public" / "binhost", ) --- /dev/null +++ b/tests/test_candidate_gate.py @@ -0,0 +1,21 @@ +from importlib.machinery import SourceFileLoader +from importlib.util import module_from_spec, spec_from_loader +from pathlib import Path +from unittest.mock import patch +import unittest + + +class CandidateGateCommandTests(unittest.TestCase): + def test_worker_protocol_does_not_require_main_process_policy(self): + script = Path(__file__).resolve().parents[1] / 'scripts/repository-candidate-gate' + loader = SourceFileLoader('candidate_gate_test', str(script)) + module = module_from_spec(spec_from_loader(loader.name, loader)) + loader.exec_module(module) + with patch.object(module.sys, 'argv', [str(script), '--worker', 'packages', '/isolated/root', '/isolated/out']), \ + patch.object(module, 'worker_packages') as worker: + self.assertEqual(module.main(), 0) + worker.assert_called_once_with('/isolated/root', '/isolated/out') + + +if __name__ == '__main__': + unittest.main() --- a/tests/test_repository_activation.py +++ b/tests/test_repository_activation.py @@ -4,6 +4,8 @@ import grp import json import os +import shutil +import subprocess import unittest from unittest.mock import patch @@ -16,6 +18,29 @@ from foundry.transition import read_toml, tree_digest from test_frontends import runtime_at from test_merge import _write_plan_ledger, _write_repos, _write_build_env + + +class PublicExportAclTests(unittest.TestCase): + @unittest.skipUnless(shutil.which('setfacl') and hasattr(os, 'listxattr'), 'POSIX ACL support required') + def test_private_workspace_acls_do_not_override_public_group_modes(self): + from foundry.repository_activation import normalize_composed_access + with TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'private-source' + source.mkdir() + recipe = source / 'example.ebuild' + recipe.write_text('EAPI=8\n') + subprocess.run(['setfacl', '-m', 'g::---,u:65534:r--,m::r--', str(recipe)], check=True) + subprocess.run(['setfacl', '-m', 'd:g::---', str(source)], check=True) + self.assertIn('system.posix_acl_access', os.listxattr(recipe)) + export = root / 'generated' + shutil.copytree(source, export) + normalize_composed_access({'activation': {'read_group': 'portage'}}, export) + self.assertNotIn('system.posix_acl_access', os.listxattr(export / recipe.name)) + self.assertNotIn('system.posix_acl_default', os.listxattr(export)) + self.assertEqual((export / recipe.name).stat().st_mode & 0o777, 0o644) + self.assertEqual(export.stat().st_mode & 0o777, 0o755) + self.assertIn('system.posix_acl_access', os.listxattr(recipe)) class RepositoryActivationTests(unittest.TestCase):