Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
3b2c657bfa Release version 0.7.12 2025-12-09 23:36:38 +01:00
Kevin Veen-Birkenbach
e335ab05a1 fix(core/ink): prevent self-referential symlinks + add unit tests
This commit adds a safety guard to create_ink() to prevent creation of
self-referential symlinks when the resolved command already lives at the
intended link target (e.g. ~/.local/bin/package-manager). Such a situation
previously resulted in broken shells with the error:

    "zsh: too many levels of symbolic links"

Key changes:
  - create_ink():
      • Introduce early-abort guard when command == link_path
      • Improve function signature and formatting
      • Enhance alias creation messaging

  - Added comprehensive unit tests under:
        tests/unit/pkgmgr/core/command/test_ink.py
    Tests cover:
      • Self-referential command path → skip symlink creation
      • Standard symlink + alias creation behaviour

This prevents pkgmgr from overwriting user-managed binaries inside ~/.local/bin
and ensures predictable, safe behaviour across all installer layers.

https://chatgpt.com/share/6938a43b-0eb8-800f-9545-6cb555ab406d
2025-12-09 23:35:29 +01:00
Kevin Veen-Birkenbach
75f963d6e2 Removed tests/e2e/test_install_all_shallow.py 2025-12-09 23:18:49 +01:00
10 changed files with 164 additions and 98 deletions

View File

@@ -1,3 +1,8 @@
## [0.7.12] - 2025-12-09
* Fixed self refering alias during setup
## [0.7.11] - 2025-12-09
* test: fix installer unit tests for OS packages and Nix dev shell

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=0.7.11
pkgver=0.7.12
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
package-manager (0.7.12-1) unstable; urgency=medium
* Fixed self refering alias during setup
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 23:36:35 +0100
package-manager (0.7.11-1) unstable; urgency=medium
* test: fix installer unit tests for OS packages and Nix dev shell

View File

@@ -31,7 +31,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "0.7.11";
version = "0.7.12";
# Use the git repo as source
src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 0.7.11
Version: 0.7.12
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -77,6 +77,9 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.12-1
- Fixed self refering alias during setup
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.11-1
- test: fix installer unit tests for OS packages and Nix dev shell

View File

@@ -6,8 +6,14 @@ from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
quiet=False, preview=False):
def create_ink(
repo,
repositories_base_dir,
bin_dir,
all_repos,
quiet: bool = False,
preview: bool = False,
) -> None:
"""
Create a symlink for the repository's command.
@@ -18,6 +24,11 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
Behavior:
- If repo["command"] is defined → create a symlink to it.
- If repo["command"] is missing or None → do NOT create a link.
Safety:
- If the resolved command path is identical to the final link target,
we skip symlink creation to avoid self-referential symlinks that
would break shell resolution ("too many levels of symbolic links").
"""
repo_identifier = get_repo_identifier(repo, all_repos)
@@ -31,6 +42,27 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
link_path = os.path.join(bin_dir, repo_identifier)
# ------------------------------------------------------------------
# Safety guard: avoid self-referential symlinks
#
# Example of a broken situation we must avoid:
# - command = ~/.local/bin/package-manager
# - link_path = ~/.local/bin/package-manager
# - create_ink() removes the real binary and creates a symlink
# pointing to itself → zsh: too many levels of symbolic links
#
# If the resolved command already lives exactly at the target path,
# we treat it as "already installed" and skip any modification.
# ------------------------------------------------------------------
if os.path.abspath(command) == os.path.abspath(link_path):
if not quiet:
print(
f"[pkgmgr] Command for '{repo_identifier}' already lives at "
f"'{link_path}'. Skipping symlink creation to avoid a "
"self-referential link."
)
return
if preview:
print(f"[Preview] Would link {link_path}{command}")
return
@@ -65,7 +97,10 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
if alias_name == repo_identifier:
if not quiet:
print(f"Alias '{alias_name}' equals identifier. Skipping alias creation.")
print(
f"Alias '{alias_name}' equals identifier. "
"Skipping alias creation."
)
return
try:

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "package-manager"
version = "0.7.11"
version = "0.7.12"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -1,91 +0,0 @@
"""
Integration test: install all configured repositories using
--clone-mode shallow (HTTPS shallow clone) and --no-verification.
This test is intended to be run inside the Docker container where:
- network access is available,
- the config/config.yaml is present,
- and it is safe to perform real git operations.
It passes if the command completes without raising an exception.
"""
import runpy
import sys
import unittest
from test_install_pkgmgr_shallow import (
nix_profile_list_debug,
remove_pkgmgr_from_nix_profile,
pkgmgr_help_debug,
)
class TestIntegrationInstallAllShallow(unittest.TestCase):
def _run_pkgmgr_install_all(self) -> None:
"""
Helper that runs the CLI command via main.py and provides
extra diagnostics if the command exits with a non-zero code.
"""
cmd_repr = "pkgmgr install --all --clone-mode shallow --no-verification"
original_argv = sys.argv
try:
sys.argv = [
"pkgmgr",
"install",
"--all",
"--clone-mode",
"shallow",
"--no-verification",
]
try:
# Execute main.py as if it was called from CLI.
# This will run the full install pipeline inside the container.
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
# Convert SystemExit into a more helpful assertion with debug output.
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print("\n[TEST] pkgmgr install --all failed with SystemExit")
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code: {exit_code}")
# Additional Nix profile debug on failure
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {exit_code}. "
"Scroll up to see the full pkgmgr/make output inside the container."
) from exc
finally:
sys.argv = original_argv
def test_install_all_repositories_shallow(self) -> None:
"""
Run: pkgmgr install --all --clone-mode shallow --no-verification
This will perform real installations/clones inside the container.
The test succeeds if no exception is raised and `pkgmgr --help`
works in a fresh interactive bash session afterwards.
"""
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
# Run the actual install with extended diagnostics
self._run_pkgmgr_install_all()
# After successful installation: show `pkgmgr --help`
# via interactive bash (same as the pkgmgr-only test).
pkgmgr_help_debug()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,108 @@
import os
import tempfile
import unittest
from unittest.mock import patch
from pkgmgr.core.command.ink import create_ink
class TestCreateInk(unittest.TestCase):
@patch("pkgmgr.core.command.ink.get_repo_dir")
@patch("pkgmgr.core.command.ink.get_repo_identifier")
def test_self_referential_command_skips_symlink(
self,
mock_get_repo_identifier,
mock_get_repo_dir,
):
"""
If the resolved command path is identical to the final link target,
create_ink() must NOT replace it with a self-referential symlink.
This simulates the situation where the command already lives at
~/.local/bin/<identifier> and we would otherwise create a symlink
pointing to itself.
"""
mock_get_repo_identifier.return_value = "package-manager"
mock_get_repo_dir.return_value = "/fake/repo"
with tempfile.TemporaryDirectory() as bin_dir:
# Simulate an existing real binary at the final link location.
command_path = os.path.join(bin_dir, "package-manager")
with open(command_path, "w", encoding="utf-8") as f:
f.write("#!/bin/sh\necho package-manager\n")
# Sanity check: not a symlink yet.
self.assertTrue(os.path.exists(command_path))
self.assertFalse(os.path.islink(command_path))
repo = {"command": command_path}
# This must NOT turn the file into a self-referential symlink.
create_ink(
repo=repo,
repositories_base_dir="/fake/base",
bin_dir=bin_dir,
all_repos=[],
quiet=True,
preview=False,
)
# After create_ink(), the file must still exist and must not be a symlink.
self.assertTrue(os.path.exists(command_path))
self.assertFalse(
os.path.islink(command_path),
"create_ink() must not create a self-referential symlink "
"when command == link_path",
)
@patch("pkgmgr.core.command.ink.get_repo_dir")
@patch("pkgmgr.core.command.ink.get_repo_identifier")
def test_create_symlink_for_normal_command(
self,
mock_get_repo_identifier,
mock_get_repo_dir,
):
"""
In the normal case (command path != link target), create_ink()
must create a symlink in bin_dir pointing to the given command,
and optionally an alias symlink when repo['alias'] is set.
"""
mock_get_repo_identifier.return_value = "mytool"
with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as bin_dir:
mock_get_repo_dir.return_value = repo_dir
# Create a fake executable inside the repository.
command_path = os.path.join(repo_dir, "main.sh")
with open(command_path, "w", encoding="utf-8") as f:
f.write("#!/bin/sh\necho mytool\n")
os.chmod(command_path, 0o755)
repo = {
"command": command_path,
"alias": "mt",
}
create_ink(
repo=repo,
repositories_base_dir="/fake/base",
bin_dir=bin_dir,
all_repos=[],
quiet=True,
preview=False,
)
link_path = os.path.join(bin_dir, "mytool")
alias_path = os.path.join(bin_dir, "mt")
# Main link must exist and point to the command.
self.assertTrue(os.path.islink(link_path))
self.assertEqual(os.readlink(link_path), command_path)
# Alias must exist and point to the main link.
self.assertTrue(os.path.islink(alias_path))
self.assertEqual(os.readlink(alias_path), link_path)
if __name__ == "__main__":
unittest.main()