From 5a5b1a190bb4333e58567fe1696c574b1a1fe10e Mon Sep 17 00:00:00 2001 From: Zacharias Date: Mon, 18 May 2026 00:46:02 +0200 Subject: [PATCH] IDK :) userman or smt.. and inital commit --- .idea/.gitignore | 5 ++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 7 ++ .idea/modules.xml | 8 ++ .idea/userman.iml | 10 +++ .idea/vcs.xml | 6 ++ config.py | 30 +++++++ db.py | 75 +++++++++++++++++ fetch.py | 25 ++++++ handoff.py | 6 ++ main.py | 43 ++++++++++ userman.conf | 82 +++++++++++++++++++ userman.sh | 18 ++++ verify.py | 15 ++++ 14 files changed, 336 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/userman.iml create mode 100644 .idea/vcs.xml create mode 100644 config.py create mode 100644 db.py create mode 100644 fetch.py create mode 100644 handoff.py create mode 100755 main.py create mode 100644 userman.conf create mode 100644 userman.sh create mode 100644 verify.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bf5217a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3dc1353 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/userman.iml b/.idea/userman.iml new file mode 100644 index 0000000..a14a94d --- /dev/null +++ b/.idea/userman.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..80c7219 --- /dev/null +++ b/config.py @@ -0,0 +1,30 @@ +import configparser +import platform +from pathlib import Path + +CONFIG_FILE = Path.home() / ".config/userman/userman.conf" + +def load_config(): + config = configparser.ConfigParser(allow_no_value=True) + config.read(str(CONFIG_FILE)) + + options = config["options"] if "options" in config else {} + + return { + "db_path": Path(options.get("DBPath", "~/.local/share/userman/db")).expanduser(), + "cache_dir": Path(options.get("CacheDir", "~/.cache/userman/pkg")).expanduser(), + "gpg_dir": Path(options.get("GPGDir", "~/.config/userman/gnupg")).expanduser(), + "hook_dir": Path(options.get("HookDir", "~/.config/userman/hooks")).expanduser(), + "log_file": Path(options.get("LogFile", "~/.local/share/userman/pacman.log")).expanduser(), + "root_dir": Path(options.get("RootDir", "~")).expanduser(), + "arch": options.get("Architecture", "auto").replace("auto", platform.machine()), + } + +def get_repos() -> dict: + config = configparser.ConfigParser(allow_no_value=True) + config.read(str(CONFIG_FILE)) + return { + section: config[section]["server"] + for section in config.sections() + if section.lower() != "options" and "server" in config[section] + } \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..86b28ec --- /dev/null +++ b/db.py @@ -0,0 +1,75 @@ +import subprocess +import tarfile +import os + +from config import load_config, CONFIG_FILE, get_repos + +DB_PATH = os.path.expanduser(load_config()["db_path"]) + +def get_package_meta(pkgname: str) -> dict | None: + for dbfile in os.listdir(DB_PATH): + if not dbfile.endwith(".db"): + continue + meta = _search_db(os.path.join(DB_PATH, dbfile), pkgname) + if meta: + return meta + return None + +def _search_db(dbfile: str, pkgname: str) -> dict | None: + with tarfile.open(dbfile, "r:gz") as tar: + for member in tar.getmembers(): + parts = member.name.split("/") + if len(parts) != 2 or parts[1] != "desc": + continue + entry_name = parts[0].rsplit("-", 2)[0] + if entry_name != pkgname: + continue + f = tar.extractfile(member) + if f: + return _parse_desc(f.read().decode("utf-8")) + +def _parse_desc(content: str) -> dict | None: + meta = {} + lines = content.strip().split("\n") + i = 0 + while i < len(lines): + if lines[i].startswith("%") and lines[i].endswith("%"): + key = lines[i].strip("%").lower() + i += 1 + values = [] + while i < len(lines) and lines[i] != "": + values.append(lines[i]) + i += 1 + meta[key] = values[0] if len(values) == 1 else values + i += 1 + return meta + +def ensure_db(): + sync_dir = load_config()["db_path"] / "sync" + + if not sync_dir.exists() or not any(sync_dir.glob("*.db")): + print(":: First run detected, syncing databases...") + _sync_db() + +def _sync_db(): + sync_dir = load_config()["db_path"] / "sync" + sync_dir.mkdir(parents=True, exist_ok=True) + + + for repo_name, server in get_repos().items(): + url = resolve_server_url(server, repo_name) + db_url = f"{url}/{repo_name}.db" + dest = sync_dir / f"{repo_name}.db" + + print(f":: Syncing {repo_name}...") + try: + subprocess.run(["curl", "-L", "-o", str(dest), db_url], check=True) + except subprocess.CalledProcessError as e: + print(f":: Probobly failed to find repo '{repo_name}' at {db_url}") + +def resolve_server_url(template: str, repo: str, arch: str = load_config()["arch"]) -> str: + return (template + .replace("$repo", repo) + .replace("$arch", arch) + .replace("${repo}", repo) + .replace("${arch}", arch)) diff --git a/fetch.py b/fetch.py new file mode 100644 index 0000000..9652b22 --- /dev/null +++ b/fetch.py @@ -0,0 +1,25 @@ +import subprocess +import os +from pathlib import Path + +from config import load_config + +CACHE_DIR = os.path.expanduser(load_config()["cache_dir"]) + +def fetch_package(meta: dict) -> tuple[Path, Path]: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + url = meta["url"] + filename = meta["filename"] + sig_url = url + ".sig" + + pkg_path = CACHE_DIR / filename + sig_path = CACHE_DIR / (filename + ".sig") + + _curl(url, pkg_path) + _curl(sig_url, sig_path) + + return pkg_path, sig_path + +def _curl(url: str, dest: Path): + print(f":: Fetching {url}") + subprocess.run(["curl", "-L", "-o", str(dest), url], check=True) diff --git a/handoff.py b/handoff.py new file mode 100644 index 0000000..31a3565 --- /dev/null +++ b/handoff.py @@ -0,0 +1,6 @@ +import subprocess +import sys + +def delegate_to_pacman(pkgname: str): + result = subprocess.run(["sudo", "pacman", "-S", pkgname]) + sys.exit(result.returncode) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100755 index 0000000..1be0356 --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +#! /bin/env python +import sys +from db import get_package_meta, ensure_db +from handoff import delegate_to_pacman +from fetch import fetch_package +from verify import verify_sig + +def main(): + + ensure_db() + + if len(sys.argv) < 3: + print("usage: userman -S ") + sys.exit(1) + + action = sys.argv[1] + pkgname = sys.argv[2] + + if action == "-S": + install(pkgname) + +def install(pkgname: str): + meta = get_package_meta(pkgname) + + if meta is None: + print(f"error: package '{pkgname}' not found is userman db") + sys.exit(1) + + if meta.get("install_mode", "system") != "user": + print(f":: Handing off '{pkgname}' to pacman") + delegate_to_pacman(pkgname) + return + + pkg_path, sig_path = fetch_package(meta) + + if not verify_sig(pkg_path, sig_path): + print("error: GPG verification failed, aborting") + sys.exit(1) + + # Send to pacman with userman flags + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/userman.conf b/userman.conf new file mode 100644 index 0000000..0a57f70 --- /dev/null +++ b/userman.conf @@ -0,0 +1,82 @@ +# +# ~/.config/userman/userman.conf +# +# See the pacman.conf(5) manpage for option and repository directives + +# +# GENERAL OPTIONS +# +[options] +# The following paths are commented out with their default values listed. +# If you wish to use different paths, uncomment and update the paths. +#RootDir = ~ +#DBPath = ~/.local/share/userman/db +#CacheDir = ~/.cache/userman/pkg +#LogFile = ~/.local/share/userman/pacman.log +#GPGDir = ~/.config/userman/gnupg +#HookDir = ~/.config/userman/hooks +HoldPkg = +#XferCommand = /usr/bin/curl -L -C - -f -o %o %u +#XferCommand = /usr/bin/wget --passive-ftp -c -O %o %u +#CleanMethod = KeepInstalled +Architecture = auto + +# Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup +#IgnorePkg = +#IgnoreGroup = + +#NoUpgrade = +#NoExtract = + +# Misc options +#UseSyslog +#Color +#NoProgressBar +CheckSpace +#VerbosePkgLists +ParallelDownloads = 5 +DownloadUser = alpm +#DisableSandboxFilesystem +#DisableSandboxSyscalls + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional +#RemoteFileSigLevel = Required + +# NOTE: You must run `pacman-key --init` before first using pacman; the local +# keyring can then be populated with the keys of all official Arch Linux +# packagers with `pacman-key --populate archlinux`. + +# +# REPOSITORIES +# - can be defined here or included from another file +# - pacman will search repositories in the order defined here +# - local/custom mirrors can be added here or in separate files +# - repositories listed first will take precedence when packages +# have identical names, regardless of version number +# - URLs will have $repo replaced by the name of the current repo +# - URLs will have $arch replaced by the name of the architecture +# +# Repository entries are of the format: +# [repo-name] +# Server = ServerName +# Include = IncludePath +# +# The header [repo-name] is crucial - it must be present and +# uncommented to enable the repo. +# + +# The testing repositories are disabled by default. To enable, uncomment the +# repo name header and Include lines. You can add preferred servers immediately +# after the header, and they will be used before the default mirrors. + +[hearth] +Server = file:///home/zacharias/Documents/userman-repo + +# An example of a custom package repository. See the pacman manpage for +# tips on creating your own repositories. +#[custom] +#SigLevel = Optional TrustAll +#Server = file:///home/custompkgs diff --git a/userman.sh b/userman.sh new file mode 100644 index 0000000..b8e686f --- /dev/null +++ b/userman.sh @@ -0,0 +1,18 @@ +#! /bin/env bash + +## The --root points to ~/ as a userman package is expected to follow this +# pacman dir | userman dir | comment +# /etc/ | ~/.local/share/ or ~/.config/ | I have seen where /etc contains things less config like and more runtime stuff wise so there for both +# /usr/bin | ~/.local/bin +# etc +# etc +# +# The isea is to replace UNIX root conventiosn with XDG conventions + +pacman --config ~/.config/userman/pacman.conf \ + --root ~/ \ + --dbpath ~/.local/share/userman/db \ + --cachedir ~/.cache/userman/pkg \ + --gpgdir ~/.config/userman/gnupg \ + --hookdir ~/.config/userman/hooks \ + --logfile ~/.local/share/userman/pacman.log \ No newline at end of file diff --git a/verify.py b/verify.py new file mode 100644 index 0000000..fba4544 --- /dev/null +++ b/verify.py @@ -0,0 +1,15 @@ +import subprocess +import os +from pathlib import Path + +from config import load_config + +GPGDIR = os.path.expanduser(load_config()["gpg_dir"]) + +def verify_sig(pkg_path: Path, sig_path: Path) -> bool: + result = subprocess.run([ + "gpg", + "--homedir", str(GPGDIR), + "--verify", str(sig_path), str(pkg_path) + ], capture_output=True) + return result.returncode == 0 \ No newline at end of file