├── .tool-versions ├── testing ├── fixtures │ ├── flatpak │ │ ├── list.txt │ │ └── search-vim.txt │ ├── yum │ │ ├── check-update-rocky8.txt │ │ ├── list-updates-rocky8.txt │ │ ├── search-empty-rocky8.txt │ │ ├── info-notfound-rocky8.txt │ │ ├── autoremove-rocky8.txt │ │ ├── update-all-dryrun-rocky8.txt │ │ ├── remove-notfound-rocky8.txt │ │ ├── install-notfound-rocky8.txt │ │ ├── clean-rocky8.txt │ │ ├── install-already-installed-rocky8.txt │ │ ├── search-vim-rocky8.txt │ │ ├── refresh-rocky8.txt │ │ ├── info-nginx-rocky8.txt │ │ ├── list-installed-minimal-rocky8.txt │ │ ├── remove-tree-rocky8.txt │ │ ├── search-nginx-rocky8.txt │ │ ├── info-vim-rocky8.txt │ │ ├── info-vim-installed-rocky8.txt │ │ ├── install-multiple-rocky8.txt │ │ └── install-vim-rocky8.txt │ ├── apt │ │ ├── dpkg-query-mixed-status.txt │ │ ├── show-vim.txt │ │ ├── apt-remove-vim.txt │ │ ├── list-upgradable.txt │ │ └── list-installed.txt │ ├── apk │ │ ├── info-vim-alpine.txt │ │ ├── search-vim-alpine.txt │ │ └── list-installed-alpine.txt │ ├── dnf │ │ └── info-vim-fedora39.txt │ └── snap │ │ ├── info-core.txt │ │ ├── find-vim.txt │ │ └── list.txt ├── docker │ ├── fedora.Dockerfile │ ├── alpine.Dockerfile │ ├── almalinux.Dockerfile │ ├── rockylinux.Dockerfile │ ├── ubuntu.Dockerfile │ ├── README.md │ ├── test-strategy.md │ └── docker-compose.test.yml ├── capture-fixtures.sh ├── testenv │ ├── testenv_test.go │ └── testenv.go └── os-matrix.yaml ├── go.mod ├── manager ├── apt │ ├── apt_test.go │ ├── utils_test.go │ └── EXIT_CODES.md ├── options.go ├── security.go ├── yum │ ├── EXIT_CODES.md │ ├── yum_test.go │ ├── yum_mock_test.go │ ├── yum_test_enhanced.go │ ├── yum_integration_test.go │ └── utils_test.go ├── snap │ ├── EXIT_CODES.md │ └── utils.go ├── command_runner_env_test.go ├── flatpak │ └── EXIT_CODES.md ├── packageinfo.go ├── command_runner_test.go ├── security_test.go └── command_runner.go ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── test-and-coverage.yml │ ├── release-binaries.yml │ ├── lint-and-format.yml │ ├── README.md │ └── multi-os-test.yml ├── osinfo ├── osinfo_test.go ├── osinfo_example_test.go ├── README.md └── osinfo.go ├── go.sum ├── .vscode └── settings.json ├── .gitignore ├── .golangci.yml ├── docs └── EXIT_CODES.md ├── .pre-commit-config.yaml ├── syspkg.go ├── Makefile ├── CHANGELOG.md ├── syspkg_test.go └── interface.go /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.24.3 2 | -------------------------------------------------------------------------------- /testing/fixtures/flatpak/list.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/fixtures/flatpak/search-vim.txt: -------------------------------------------------------------------------------- 1 | Flatpak 1.12.7 2 | No matches found 3 | -------------------------------------------------------------------------------- /testing/fixtures/yum/check-update-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:37 ago on Sat May 31 07:58:28 2025. 2 | -------------------------------------------------------------------------------- /testing/fixtures/yum/list-updates-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:38 ago on Sat May 31 07:58:28 2025. 2 | -------------------------------------------------------------------------------- /testing/fixtures/yum/search-empty-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:15 ago on Sat May 31 05:12:33 2025. 2 | No matches found. 3 | -------------------------------------------------------------------------------- /testing/fixtures/yum/info-notfound-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:23 ago on Sat May 31 05:14:12 2025. 2 | Error: No matching Packages to list 3 | -------------------------------------------------------------------------------- /testing/fixtures/yum/autoremove-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:39 ago on Sat May 31 07:58:28 2025. 2 | Dependencies resolved. 3 | Nothing to do. 4 | Complete! 5 | -------------------------------------------------------------------------------- /testing/fixtures/yum/update-all-dryrun-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:40 ago on Sat May 31 07:58:28 2025. 2 | Dependencies resolved. 3 | Nothing to do. 4 | Complete! 5 | -------------------------------------------------------------------------------- /testing/fixtures/yum/remove-notfound-rocky8.txt: -------------------------------------------------------------------------------- 1 | No match for argument: nonexistent-package-12345 2 | No packages marked for removal. 3 | Dependencies resolved. 4 | Nothing to do. 5 | Complete! 6 | -------------------------------------------------------------------------------- /testing/fixtures/apt/dpkg-query-mixed-status.txt: -------------------------------------------------------------------------------- 1 | dpkg-query: no packages found matching vim-tiny 2 | adduser install ok installed 3.118ubuntu5 3 | apt install ok installed 2.4.13 4 | package not found 5 | -------------------------------------------------------------------------------- /testing/fixtures/yum/install-notfound-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:57 ago on Sat May 31 07:53:22 2025. 2 | No match for argument: nonexistent-package-12345 3 | Error: Unable to find a match: nonexistent-package-12345 4 | -------------------------------------------------------------------------------- /testing/fixtures/yum/clean-rocky8.txt: -------------------------------------------------------------------------------- 1 | Updating Subscription Management repositories. 2 | Unable to read consumer identity 3 | 4 | This system is not registered with an entitlement server. You can use subscription-manager to register. 5 | 6 | 14 files removed 7 | -------------------------------------------------------------------------------- /testing/fixtures/yum/install-already-installed-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:56 ago on Sat May 31 07:53:22 2025. 2 | Package vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64 is already installed. 3 | Dependencies resolved. 4 | Nothing to do. 5 | Complete! 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bluet/syspkg 2 | 3 | go 1.23 4 | 5 | require github.com/urfave/cli/v2 v2.27.7 // direct 6 | 7 | require ( 8 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 9 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 10 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /testing/fixtures/apk/info-vim-alpine.txt: -------------------------------------------------------------------------------- 1 | gvim-9.0.2073-r0 description: 2 | advanced text editor, with GUI 3 | 4 | gvim-9.0.2073-r0 webpage: 5 | https://www.vim.org/ 6 | 7 | gvim-9.0.2073-r0 installed size: 8 | 2996 KiB 9 | 10 | vim-9.0.2073-r0 description: 11 | Improved vi-style text editor 12 | 13 | vim-9.0.2073-r0 webpage: 14 | https://www.vim.org/ 15 | 16 | vim-9.0.2073-r0 installed size: 17 | 2692 KiB 18 | -------------------------------------------------------------------------------- /manager/apt/apt_test.go: -------------------------------------------------------------------------------- 1 | // apt/apt_test.go 2 | package apt_test 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/bluet/syspkg/manager/apt" 8 | ) 9 | 10 | func TestAptPackageManager(t *testing.T) { 11 | // Implement test cases for AptPackageManager 12 | aptManager := &apt.PackageManager{} 13 | if aptManager.IsAvailable() { 14 | t.Log("AptPackageManager is available") 15 | } else { 16 | t.Fatal("AptPackageManager is not available") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /testing/fixtures/yum/search-vim-rocky8.txt: -------------------------------------------------------------------------------- 1 | ========================= Name & Summary Matched: vim ========================== 2 | vim-X11.x86_64 : The VIM version of the vi editor for the X Window System - GVim 3 | vim-common.x86_64 : The common files needed by any version of the VIM editor 4 | vim-enhanced.x86_64 : A version of the VIM editor which includes recent enhancements 5 | vim-filesystem.noarch : VIM filesystem layout 6 | vim-minimal.x86_64 : A minimal version of the VIM editor 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /osinfo/osinfo_test.go: -------------------------------------------------------------------------------- 1 | package osinfo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetOSInfo(t *testing.T) { 8 | osInfo, err := GetOSInfo() 9 | if err != nil { 10 | t.Fatalf("GetOSInfo() failed with error: %v", err) 11 | } 12 | 13 | if osInfo.Name == "" { 14 | t.Errorf("OS name is empty") 15 | } 16 | 17 | if osInfo.Version == "" { 18 | t.Errorf("OS version is empty") 19 | } 20 | 21 | if osInfo.Distribution == "" { 22 | t.Errorf("OS distribution is empty") 23 | } 24 | 25 | if osInfo.Arch == "" { 26 | t.Errorf("OS architecture is empty") 27 | } 28 | 29 | t.Logf("OS Info: %+v", osInfo) 30 | } 31 | -------------------------------------------------------------------------------- /osinfo/osinfo_example_test.go: -------------------------------------------------------------------------------- 1 | // osinfo_example_test.go 2 | package osinfo_test 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/bluet/syspkg/osinfo" 8 | ) 9 | 10 | // ExampleGetOSInfo demonstrates how to use the GetOSInfo function to obtain 11 | // information about the operating system. 12 | func ExampleGetOSInfo() { 13 | osInfo, err := osinfo.GetOSInfo() 14 | if err != nil { 15 | fmt.Println("Error getting OS info:", err) 16 | return 17 | } 18 | 19 | fmt.Println("Name:", osInfo.Name) 20 | fmt.Println("Distribution:", osInfo.Distribution) 21 | fmt.Println("Version:", osInfo.Version) 22 | fmt.Println("Architecture:", osInfo.Arch) 23 | } 24 | -------------------------------------------------------------------------------- /testing/fixtures/yum/refresh-rocky8.txt: -------------------------------------------------------------------------------- 1 | Updating Subscription Management repositories. 2 | Unable to read consumer identity 3 | 4 | This system is not registered with an entitlement server. You can use subscription-manager to register. 5 | 6 | Rocky Linux 8 - AppStream 3.2 kB/s | 4.8 kB 00:01 7 | Rocky Linux 8 - BaseOS 3.1 kB/s | 4.3 kB 00:01 8 | Rocky Linux 8 - Extras 2.9 kB/s | 3.5 kB 00:01 9 | Metadata cache created. 10 | -------------------------------------------------------------------------------- /testing/fixtures/yum/info-nginx-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 5:16:10 ago on Thu 22 May 2025 04:30:18 PM UTC. 2 | Available Packages 3 | Name : nginx 4 | Epoch : 2 5 | Version : 1.20.1 6 | Release : 20.el9.0.1 7 | Architecture : x86_64 8 | Size : 36 k 9 | Source : nginx-1.20.1-20.el9.0.1.src.rpm 10 | Repository : appstream 11 | Summary : A high performance web server and reverse proxy server 12 | URL : https://nginx.org 13 | License : BSD 14 | Description : Nginx is a web server and a reverse proxy server for HTTP, SMTP, POP3 and 15 | : IMAP protocols, with a strong focus on high concurrency, performance and low 16 | : memory usage. 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 3 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 4 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 5 | github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 6 | github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 7 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 8 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 9 | -------------------------------------------------------------------------------- /testing/docker/fedora.Dockerfile: -------------------------------------------------------------------------------- 1 | # Fedora test container for go-syspkg (DNF testing) 2 | FROM fedora:39 3 | 4 | # Install build dependencies and DNF 5 | RUN dnf update -y && dnf install -y \ 6 | dnf-utils \ 7 | curl \ 8 | git \ 9 | make \ 10 | which \ 11 | golang \ 12 | && dnf clean all 13 | 14 | # Set working directory 15 | WORKDIR /workspace 16 | 17 | # Copy go mod files for dependency caching 18 | COPY go.mod go.sum ./ 19 | RUN go mod download 20 | 21 | # Set test environment variables 22 | ENV IN_CONTAINER=true 23 | ENV CGO_ENABLED=0 24 | ENV TEST_OS=fedora 25 | ENV TEST_OS_VERSION=39 26 | ENV TEST_PACKAGE_MANAGER=dnf 27 | 28 | # Default command runs DNF-specific tests 29 | CMD ["go", "test", "-v", "-tags=unit,integration", "./manager/dnf", "./osinfo"] 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "adedev", 4 | "ajsdjsks", 5 | "binfmt", 6 | "blablaland", 7 | "cydia", 8 | "deinstall", 9 | "dfsg", 10 | "distro", 11 | "dpkg", 12 | "eopkg", 13 | "Fatalf", 14 | "flatpak", 15 | "freerdp", 16 | "goarch", 17 | "libllvm", 18 | "libslf", 19 | "osinfo", 20 | "pkgs", 21 | "sindresorhus", 22 | "struct", 23 | "syspkg", 24 | "urfave", 25 | "wangyoucao", 26 | "xbps", 27 | "Zorin", 28 | "zutty", 29 | "zvbi" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /testing/fixtures/apt/show-vim.txt: -------------------------------------------------------------------------------- 1 | Package: vim 2 | Version: 2:8.2.3995-1ubuntu2.24 3 | Priority: optional 4 | Section: editors 5 | Origin: Ubuntu 6 | Maintainer: Ubuntu Developers 7 | Original-Maintainer: Debian Vim Maintainers 8 | Bugs: https://bugs.launchpad.net/ubuntu/+filebug 9 | Installed-Size: 4025 kB 10 | Provides: editor 11 | Depends: vim-common (= 2:8.2.3995-1ubuntu2.24), vim-runtime (= 2:8.2.3995-1ubuntu2.24), libacl1 (>= 2.2.23), libc6 (>= 2.34), libgpm2 (>= 1.20.7), libpython3.10 (>= 3.10.0), libselinux1 (>= 3.1~), libsodium23 (>= 1.0.14), libtinfo6 (>= 6) 12 | Suggests: ctags, vim-doc, vim-scripts 13 | Homepage: https://www.vim.org/ 14 | Task: cloud-image, ubuntu-wsl, server, ubuntu-server-raspi, lubuntu-desktop 15 | Download-Size: 1728 kB 16 | APT-Sources: http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages 17 | Description: Vi IMproved - enhanced vi editor 18 | -------------------------------------------------------------------------------- /manager/apt/utils_test.go: -------------------------------------------------------------------------------- 1 | package apt_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bluet/syspkg/manager/apt" 7 | ) 8 | 9 | // TestPackageManager_IsAvailable tests the basic availability check behavior 10 | func TestPackageManager_IsAvailable(t *testing.T) { 11 | pm := &apt.PackageManager{} 12 | 13 | // Test behavior: IsAvailable should return a boolean 14 | available := pm.IsAvailable() 15 | 16 | // We don't assert the specific value since it depends on the system 17 | // We just test that the method doesn't panic and returns a boolean 18 | _ = available 19 | } 20 | 21 | // TestPackageManager_GetPackageManager tests the identifier behavior 22 | func TestPackageManager_GetPackageManager(t *testing.T) { 23 | pm := &apt.PackageManager{} 24 | 25 | // Test contract: Should always return "apt" 26 | if pm.GetPackageManager() != "apt" { 27 | t.Errorf("GetPackageManager() should return 'apt', got '%s'", pm.GetPackageManager()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | go-version: ['1.23', '1.24'] 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | cache: true 32 | 33 | - name: Install dependencies 34 | run: go mod download 35 | 36 | - name: Build 37 | run: go build -v ./... 38 | 39 | - name: Build CLI binary 40 | run: go build -v ./cmd/syspkg 41 | -------------------------------------------------------------------------------- /testing/fixtures/yum/list-installed-minimal-rocky8.txt: -------------------------------------------------------------------------------- 1 | Installed Packages 2 | NetworkManager.x86_64 1:1.48.10-2.el9_5 @baseos 3 | rocky-release.noarch 9.5-1.2.el9 @baseos 4 | rpm.x86_64 4.16.1.3-34.el9.0.1 @baseos 5 | rsync.x86_64 3.2.3-20.el9 @baseos 6 | perl-DBD-MySQL.x86_64 4.050-10.el9 @appstream 7 | -------------------------------------------------------------------------------- /testing/fixtures/apk/search-vim-alpine.txt: -------------------------------------------------------------------------------- 1 | apparmor-vim-3.1.7-r0 2 | faenza-icon-theme-gvim-1.3.1-r6 3 | faenza-icon-theme-vim-1.3.1-r6 4 | fzf-neovim-0.40.0-r5 5 | fzf-vim-0.40.0-r5 6 | geany-plugins-vimode-1.38-r2 7 | graphviz-8.0.5-r2 8 | gst-plugins-base-1.22.12-r0 9 | gvim-9.0.2073-r0 10 | hare-vim-0_git20230225-r0 11 | icinga2-vim-2.13.7-r0 12 | kmymoney-5.1.3-r2 13 | mercurial-vim-6.4.5-r0 14 | meson-vim-1.1.0-r1 15 | msmtp-vim-1.8.23-r0 16 | neovim-0.9.2-r0 17 | neovim-doc-0.9.2-r0 18 | neovim-lang-0.9.2-r0 19 | nftables-vim-0_git20200629-r1 20 | nginx-vim-1.24.0-r7 21 | notmuch-vim-0.37-r2 22 | protobuf-vim-3.21.12-r2 23 | py3-pynvim-0.4.3-r6 24 | py3-pynvim-pyc-0.4.3-r6 25 | py3-pyvmomi-8.0.0.1.2-r1 26 | runvimtests-1.30-r2 27 | skim-vim-plugin-0.10.4-r0 28 | u-boot-tools-2023.04-r5 29 | vim-9.0.2073-r0 30 | vim-common-9.0.2073-r0 31 | vim-doc-9.0.2073-r0 32 | vim-editorconfig-0.8.0-r0 33 | vim-go-1.28-r4 34 | vim-sleuth-2.0-r0 35 | vim-tutor-9.0.2073-r0 36 | vimb-3.6.0-r2 37 | vimb-doc-3.6.0-r2 38 | vimdiff-9.0.2073-r0 39 | -------------------------------------------------------------------------------- /manager/options.go: -------------------------------------------------------------------------------- 1 | // Package manager provides utilities for managing the application. 2 | package manager 3 | 4 | // Options represents the various configuration options for the application. 5 | type Options struct { 6 | // Interactive indicates whether the application should run in interactive mode. 7 | Interactive bool 8 | 9 | // DryRun indicates whether the application should simulate actions without actually performing them. 10 | DryRun bool 11 | 12 | // Verbose indicates whether the application should output additional information during execution. 13 | Verbose bool 14 | 15 | // AssumeYes indicates whether the application should automatically confirm any prompts without user input. 16 | AssumeYes bool 17 | 18 | // Debug indicates whether the application should run in debug mode, providing more detailed information about its internal operations. 19 | Debug bool 20 | 21 | // CustomCommandArgs is a slice of strings that can be used to pass additional custom arguments to the application. 22 | CustomCommandArgs []string 23 | } 24 | -------------------------------------------------------------------------------- /testing/fixtures/yum/remove-tree-rocky8.txt: -------------------------------------------------------------------------------- 1 | Dependencies resolved. 2 | ================================================================================ 3 | Package Architecture Version Repository Size 4 | ================================================================================ 5 | Removing: 6 | tree x86_64 1.7.0-15.el8 @baseos 106 k 7 | 8 | Transaction Summary 9 | ================================================================================ 10 | Remove 1 Package 11 | 12 | Freed space: 106 k 13 | Running transaction check 14 | Transaction check succeeded. 15 | Running transaction test 16 | Transaction test succeeded. 17 | Running transaction 18 | Preparing : 1/1 19 | Erasing : tree-1.7.0-15.el8.x86_64 1/1 20 | Verifying : tree-1.7.0-15.el8.x86_64 1/1 21 | 22 | Removed: 23 | tree-1.7.0-15.el8.x86_64 24 | 25 | Complete! 26 | -------------------------------------------------------------------------------- /testing/docker/alpine.Dockerfile: -------------------------------------------------------------------------------- 1 | # Alpine test container for go-syspkg 2 | FROM alpine:3.18 3 | 4 | # Install build dependencies and apk package manager 5 | RUN apk add --no-cache \ 6 | curl \ 7 | tar \ 8 | git \ 9 | make \ 10 | alpine-sdk \ 11 | bash 12 | 13 | # Install Go using Docker's TARGETARCH ARG for platform detection 14 | ARG GO_VERSION=1.23.4 15 | ARG TARGETARCH 16 | SHELL ["/bin/bash", "-euxo", "pipefail", "-c"] 17 | RUN if [ -z "${TARGETARCH}" ]; then \ 18 | echo "Error: TARGETARCH is not set. Use a BuildKit-enabled builder." >&2; \ 19 | exit 1; \ 20 | fi && \ 21 | curl -L "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" | tar -C /usr/local -xz 22 | ENV PATH="/usr/local/go/bin:${PATH}" 23 | 24 | # Set working directory 25 | WORKDIR /workspace 26 | 27 | # Install test dependencies 28 | COPY go.mod go.sum ./ 29 | RUN go mod download 30 | 31 | # Set test environment 32 | ENV IN_CONTAINER=true 33 | ENV CGO_ENABLED=0 34 | 35 | # Default command runs tests 36 | CMD ["go", "test", "-v", "./..."] 37 | -------------------------------------------------------------------------------- /testing/fixtures/apk/list-installed-alpine.txt: -------------------------------------------------------------------------------- 1 | alpine-baselayout-3.4.3-r1 x86_64 {alpine-baselayout} (GPL-2.0-only) [installed] 2 | alpine-baselayout-data-3.4.3-r1 x86_64 {alpine-baselayout} (GPL-2.0-only) [installed] 3 | alpine-keys-2.4-r1 x86_64 {alpine-keys} (MIT) [installed] 4 | apk-tools-2.14.4-r0 x86_64 {apk-tools} (GPL-2.0-only) [installed] 5 | busybox-1.36.1-r7 x86_64 {busybox} (GPL-2.0-only) [installed] 6 | busybox-binsh-1.36.1-r7 x86_64 {busybox} (GPL-2.0-only) [installed] 7 | ca-certificates-bundle-20241121-r1 x86_64 {ca-certificates} (MPL-2.0 AND MIT) [installed] 8 | libc-utils-0.7.2-r5 x86_64 {libc-dev} (BSD-2-Clause AND BSD-3-Clause) [installed] 9 | libcrypto3-3.1.8-r0 x86_64 {openssl} (Apache-2.0) [installed] 10 | libssl3-3.1.8-r0 x86_64 {openssl} (Apache-2.0) [installed] 11 | musl-1.2.4-r3 x86_64 {musl} (MIT) [installed] 12 | musl-utils-1.2.4-r3 x86_64 {musl} (MIT AND BSD-2-Clause AND GPL-2.0-or-later) [installed] 13 | scanelf-1.3.7-r1 x86_64 {pax-utils} (GPL-2.0-only) [installed] 14 | ssl_client-1.36.1-r7 x86_64 {busybox} (GPL-2.0-only) [installed] 15 | zlib-1.2.13-r1 x86_64 {zlib} (Zlib) [installed] 16 | -------------------------------------------------------------------------------- /.github/workflows/test-and-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Test and Coverage 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | name: Test and Coverage 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest] 19 | go-version: ['1.23', '1.24'] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | cache: true 30 | 31 | - name: Install dependencies 32 | run: go mod download 33 | 34 | - name: Run tests 35 | run: | 36 | go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 37 | 38 | - name: Upload coverage to Codecov 39 | if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.23' 40 | uses: codecov/codecov-action@v4 41 | with: 42 | file: ./coverage.txt 43 | flags: unittests 44 | name: codecov-umbrella 45 | -------------------------------------------------------------------------------- /testing/docker/almalinux.Dockerfile: -------------------------------------------------------------------------------- 1 | # AlmaLinux test container for go-syspkg (YUM testing) 2 | 3 | FROM almalinux:8 4 | 5 | # Install build dependencies and YUM 6 | RUN yum update -y && yum install -y \ 7 | yum-utils \ 8 | curl \ 9 | git \ 10 | make \ 11 | which \ 12 | && yum clean all 13 | 14 | # Install Go using Docker's TARGETARCH ARG for platform detection 15 | ARG GO_VERSION=1.23.4 16 | ARG TARGETARCH 17 | RUN if [ -z "${TARGETARCH}" ]; then \ 18 | echo "Error: TARGETARCH is not set. Use a BuildKit-enabled builder." >&2; \ 19 | exit 1; \ 20 | fi && \ 21 | curl -L "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" | tar -C /usr/local -xz 22 | ENV PATH="/usr/local/go/bin:${PATH}" 23 | ENV GOROOT="/usr/local/go" 24 | 25 | # Set working directory 26 | WORKDIR /workspace 27 | 28 | # Copy go mod files for dependency caching 29 | COPY go.mod go.sum ./ 30 | RUN go mod download 31 | 32 | # Set test environment variables 33 | ENV IN_CONTAINER=true 34 | ENV CGO_ENABLED=0 35 | ENV TEST_OS=almalinux 36 | ENV TEST_OS_VERSION=8 37 | ENV TEST_PACKAGE_MANAGER=yum 38 | 39 | # Default command runs YUM-specific tests 40 | CMD ["go", "test", "-v", "-tags=unit,integration", "./manager/yum", "./osinfo"] 41 | -------------------------------------------------------------------------------- /testing/fixtures/yum/search-nginx-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 4:56:00 ago on Thu 22 May 2025 04:30:18 PM UTC. 2 | ============================================================================= Name Exactly Matched: nginx ============================================================================== 3 | nginx.x86_64 : A high performance web server and reverse proxy server 4 | ============================================================================ Name & Summary Matched: nginx ============================================================================= 5 | nginx-all-modules.noarch : A meta package that installs all available Nginx modules 6 | nginx-core.x86_64 : nginx minimal core 7 | nginx-filesystem.noarch : The basic directory layout for the Nginx server 8 | nginx-mod-http-image-filter.x86_64 : Nginx HTTP image filter module 9 | nginx-mod-http-perl.x86_64 : Nginx HTTP perl module 10 | nginx-mod-http-xslt-filter.x86_64 : Nginx XSLT module 11 | nginx-mod-mail.x86_64 : Nginx mail modules 12 | nginx-mod-stream.x86_64 : Nginx stream modules 13 | pcp-pmda-nginx.x86_64 : Performance Co-Pilot (PCP) metrics for the Nginx Webserver 14 | perl-DBD-MySQL.x86_64 : A MySQL interface for Perl 15 | libreoffice-langpack-en.x86_64 : English language pack for LibreOffice 16 | -------------------------------------------------------------------------------- /testing/docker/rockylinux.Dockerfile: -------------------------------------------------------------------------------- 1 | # Rocky Linux test container for go-syspkg (YUM testing) 2 | FROM rockylinux:8 3 | 4 | # Install build dependencies and YUM 5 | RUN yum update -y && yum install -y \ 6 | yum-utils \ 7 | curl \ 8 | git \ 9 | make \ 10 | which \ 11 | && yum clean all 12 | 13 | # Install Go using Docker's TARGETARCH ARG for platform detection 14 | ARG GO_VERSION=1.23.4 15 | ARG TARGETARCH 16 | SHELL ["/bin/bash", "-euxo", "pipefail", "-c"] 17 | RUN if [ -z "${TARGETARCH}" ]; then \ 18 | echo "Error: TARGETARCH is not set. Use a BuildKit-enabled builder." >&2; \ 19 | exit 1; \ 20 | fi && \ 21 | curl -L "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" | tar -C /usr/local -xz 22 | ENV PATH="/usr/local/go/bin:${PATH}" 23 | ENV GOROOT="/usr/local/go" 24 | 25 | # Set working directory 26 | WORKDIR /workspace 27 | 28 | # Copy go mod files for dependency caching 29 | COPY go.mod go.sum ./ 30 | RUN go mod download 31 | 32 | # Set test environment variables 33 | ENV IN_CONTAINER=true 34 | ENV CGO_ENABLED=0 35 | ENV TEST_OS=rockylinux 36 | ENV TEST_OS_VERSION=8 37 | ENV TEST_PACKAGE_MANAGER=yum 38 | 39 | # Default command runs YUM-specific tests 40 | CMD ["go", "test", "-v", "-tags=unit,integration", "./manager/yum", "./osinfo"] 41 | -------------------------------------------------------------------------------- /.github/workflows/release-binaries.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/go-release-binaries.yml 2 | name: Release Binaries 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | releases-matrix: 13 | name: Release Go Binary 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 18 | goos: [linux, windows, darwin] 19 | goarch: ["386", amd64, arm64] 20 | exclude: 21 | - goarch: "386" 22 | goos: darwin 23 | - goarch: arm64 24 | goos: windows 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: '1.24' 33 | cache: true 34 | 35 | - name: Install dependencies 36 | run: go mod download 37 | 38 | - uses: wangyoucao577/go-release-action@v1 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | goos: ${{ matrix.goos }} 42 | goarch: ${{ matrix.goarch }} 43 | go_version: '1.24' 44 | project_path: ./cmd/syspkg 45 | binary_name: syspkg 46 | extra_files: LICENSE README.md 47 | -------------------------------------------------------------------------------- /testing/fixtures/yum/info-vim-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:39 ago on Sat May 31 04:19:59 2025. 2 | Available Packages 3 | Name : vim-enhanced 4 | Epoch : 2 5 | Version : 8.0.1763 6 | Release : 19.el8_6.4 7 | Architecture : x86_64 8 | Size : 1.4 M 9 | Source : vim-8.0.1763-19.el8_6.4.src.rpm 10 | Repository : appstream 11 | Summary : A version of the VIM editor which includes recent enhancements 12 | URL : http://www.vim.org/ 13 | License : Vim and MIT 14 | Description : VIM (VIsual editor iMproved) is an updated and improved version of the 15 | : vi editor. Vi was the first real screen-based editor for UNIX, and is 16 | : still very popular. VIM improves on vi by adding new features: 17 | : multiple windows, multi-level undo, block highlighting and more. The 18 | : vim-enhanced package contains a version of VIM with extra, recently 19 | : introduced features like Python and Perl interpreters. 20 | : 21 | : Install the vim-enhanced package if you'd like to use a version of the 22 | : VIM editor which includes recently added enhancements like 23 | : interpreters for the Python and Perl scripting languages. You'll also 24 | : need to install the vim-common package. 25 | -------------------------------------------------------------------------------- /testing/fixtures/yum/info-vim-installed-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:08 ago on Sat May 31 04:37:18 2025. 2 | Installed Packages 3 | Name : vim-enhanced 4 | Epoch : 2 5 | Version : 8.0.1763 6 | Release : 19.el8_6.4 7 | Architecture : x86_64 8 | Size : 2.9 M 9 | Source : vim-8.0.1763-19.el8_6.4.src.rpm 10 | Repository : @System 11 | From repo : appstream 12 | Summary : A version of the VIM editor which includes recent enhancements 13 | URL : http://www.vim.org/ 14 | License : Vim and MIT 15 | Description : VIM (VIsual editor iMproved) is an updated and improved version of the 16 | : vi editor. Vi was the first real screen-based editor for UNIX, and is 17 | : still very popular. VIM improves on vi by adding new features: 18 | : multiple windows, multi-level undo, block highlighting and more. The 19 | : vim-enhanced package contains a version of VIM with extra, recently 20 | : introduced features like Python and Perl interpreters. 21 | : 22 | : Install the vim-enhanced package if you'd like to use a version of the 23 | : VIM editor which includes recently added enhancements like 24 | : interpreters for the Python and Perl scripting languages. You'll also 25 | : need to install the vim-common package. 26 | -------------------------------------------------------------------------------- /testing/fixtures/apt/apt-remove-vim.txt: -------------------------------------------------------------------------------- 1 | WARNING: apt does not have a stable CLI interface. Use with caution in scripts. 2 | Reading package lists... 3 | Building dependency tree... 4 | Reading state information... 5 | The following packages were automatically installed and are no longer required: 6 | libexpat1 libmpdec3 libpython3.10 libpython3.10-minimal libpython3.10-stdlib 7 | libreadline8 libsodium23 libsqlite3-0 media-types readline-common vim-common 8 | vim-runtime xxd 9 | Use 'apt autoremove' to remove them. 10 | The following packages will be REMOVED: 11 | vim 12 | 0 upgraded, 0 newly installed, 1 to remove and 21 not upgraded. 13 | After this operation, 4025 kB disk space will be freed. 14 | Do you want to continue? [Y/n] (Reading database ... 15 | (Reading database ... 5% 16 | (Reading database ... 10% 17 | (Reading database ... 15% 18 | (Reading database ... 20% 19 | (Reading database ... 25% 20 | (Reading database ... 30% 21 | (Reading database ... 35% 22 | (Reading database ... 40% 23 | (Reading database ... 45% 24 | (Reading database ... 50% 25 | (Reading database ... 55% 26 | (Reading database ... 60% 27 | (Reading database ... 65% 28 | (Reading database ... 70% 29 | (Reading database ... 75% 30 | (Reading database ... 80% 31 | (Reading database ... 85% 32 | (Reading database ... 90% 33 | (Reading database ... 95% 34 | (Reading database ... 100% 35 | (Reading database ... 7110 files and directories currently installed.) 36 | Removing vim (2:8.2.3995-1ubuntu2.24) ... 37 | -------------------------------------------------------------------------------- /testing/docker/ubuntu.Dockerfile: -------------------------------------------------------------------------------- 1 | # Ubuntu test container for go-syspkg 2 | FROM ubuntu:22.04 3 | 4 | # Avoid interactive prompts 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | 7 | # Install package managers and dependencies 8 | RUN apt-get update && apt-get install -y \ 9 | apt-utils \ 10 | software-properties-common \ 11 | flatpak \ 12 | curl \ 13 | git \ 14 | make \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | # Install Go using Docker's TARGETARCH ARG for platform detection 18 | ARG GO_VERSION=1.23.4 19 | ARG TARGETARCH 20 | SHELL ["/bin/bash", "-euxo", "pipefail", "-c"] 21 | RUN if [ -z "${TARGETARCH}" ]; then \ 22 | echo "Error: TARGETARCH is not set. Use a BuildKit-enabled builder." >&2; \ 23 | exit 1; \ 24 | fi && \ 25 | curl -L "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" | tar -C /usr/local -xz 26 | ENV PATH="/usr/local/go/bin:${PATH}" 27 | 28 | # Note: snap requires systemd which doesn't work in standard Docker containers 29 | # Options for snap testing: 30 | # 1. Use mock data for snap command outputs 31 | # 2. Use GitHub Actions with native Ubuntu runners 32 | # 3. Use systemd-enabled containers (complex setup) 33 | 34 | # Set working directory 35 | WORKDIR /workspace 36 | 37 | # Install test dependencies 38 | COPY go.mod go.sum ./ 39 | RUN go mod download 40 | 41 | # Set test environment 42 | ENV IN_CONTAINER=true 43 | ENV CGO_ENABLED=0 44 | 45 | # Default command runs tests 46 | CMD ["go", "test", "-v", "./..."] 47 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-format.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Format 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | name: Lint and Format Check 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: '1.23' 25 | cache: true 26 | 27 | - name: Install dependencies 28 | run: | 29 | go mod download 30 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 31 | 32 | - name: Run gofmt 33 | run: | 34 | # Check if gofmt reports any formatting issues 35 | if [ -n "$(gofmt -l .)" ]; then 36 | echo "The following files need formatting:" 37 | gofmt -l . 38 | echo "" 39 | echo "Please run 'gofmt -w .' to format your code" 40 | exit 1 41 | fi 42 | 43 | - name: Run go vet 44 | run: go vet ./... 45 | 46 | - name: Run golangci-lint 47 | run: golangci-lint run --timeout=5m 48 | 49 | - name: Run go mod tidy check 50 | run: | 51 | go mod tidy 52 | if [ -n "$(git status --porcelain)" ]; then 53 | echo "go mod tidy produced changes:" 54 | git diff 55 | echo "" 56 | echo "Please run 'go mod tidy' and commit the changes" 57 | exit 1 58 | fi 59 | -------------------------------------------------------------------------------- /testing/fixtures/dnf/info-vim-fedora39.txt: -------------------------------------------------------------------------------- 1 | Fedora 39 - x86_64 6.8 MB/s | 89 MB 00:13 2 | Fedora 39 openh264 (From Cisco) - x86_64 931 B/s | 2.6 kB 00:02 3 | Fedora 39 - x86_64 - Updates 6.2 MB/s | 42 MB 00:06 4 | Available Packages 5 | Name : vim-enhanced 6 | Epoch : 2 7 | Version : 9.1.825 8 | Release : 1.fc39 9 | Architecture : x86_64 10 | Size : 1.9 M 11 | Source : vim-9.1.825-1.fc39.src.rpm 12 | Repository : updates 13 | Summary : A version of the VIM editor which includes recent enhancements 14 | URL : http://www.vim.org/ 15 | License : Vim AND LGPL-2.1-or-later AND MIT AND GPL-1.0-only AND (GPL-2.0-only OR Vim) AND Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND GPL-2.0-or-later AND GPL-3.0-or-later AND OPUBL-1.0 AND Apache-2.0 WITH Swift-exception 16 | Description : VIM (VIsual editor iMproved) is an updated and improved version of the 17 | : vi editor. Vi was the first real screen-based editor for UNIX, and is 18 | : still very popular. VIM improves on vi by adding new features: 19 | : multiple windows, multi-level undo, block highlighting and more. The 20 | : vim-enhanced package contains a version of VIM with extra, recently 21 | : introduced features like Python and Perl interpreters. 22 | : 23 | : Install the vim-enhanced package if you'd like to use a version of the 24 | : VIM editor which includes recently added enhancements like 25 | : interpreters for the Python and Perl scripting languages. You'll also 26 | : need to install the vim-common package. 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/go,visualstudiocode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,visualstudiocode 4 | 5 | ### Go ### 6 | # If you prefer the allow list template instead of the deny list, see community template: 7 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 8 | # 9 | # Binaries for programs and plugins 10 | *.exe 11 | *.exe~ 12 | *.dll 13 | *.so 14 | *.dylib 15 | 16 | # Test binary, built with `go test -c` 17 | *.test 18 | 19 | # Output of the go coverage tool, specifically when used with LiteIDE 20 | *.out 21 | 22 | # Dependency directories (remove the comment below to include it) 23 | # vendor/ 24 | 25 | # Go workspace file 26 | go.work 27 | 28 | ### VisualStudioCode ### 29 | .vscode/* 30 | !.vscode/settings.json 31 | !.vscode/tasks.json 32 | !.vscode/launch.json 33 | !.vscode/extensions.json 34 | !.vscode/*.code-snippets 35 | 36 | # Local History for Visual Studio Code 37 | .history/ 38 | 39 | # Built Visual Studio Code Extensions 40 | *.vsix 41 | 42 | ### VisualStudioCode Patch ### 43 | # Ignore all local history of files 44 | .history 45 | .ionide 46 | 47 | # End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode 48 | 49 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 50 | 51 | bin/ 52 | tmp/ 53 | **/*.log.dccache 54 | 55 | # Vim swap files 56 | *.swp 57 | *.swo 58 | *~ 59 | 60 | # Development cache and temporary files 61 | .dccache 62 | *.log 63 | 64 | # Temporary development notes (not part of official docs) 65 | *_improvements.md 66 | *_notes.md 67 | *_todo.md 68 | TODO.md 69 | -------------------------------------------------------------------------------- /testing/fixtures/apt/list-upgradable.txt: -------------------------------------------------------------------------------- 1 | Listing... 2 | apt/jammy-updates 2.4.14 amd64 [upgradable from: 2.4.13] 3 | gpgv/jammy-updates,jammy-security 2.2.27-3ubuntu2.3 amd64 [upgradable from: 2.2.27-3ubuntu2.1] 4 | libapt-pkg6.0/jammy-updates 2.4.14 amd64 [upgradable from: 2.4.13] 5 | libc-bin/jammy-updates,jammy-security 2.35-0ubuntu3.10 amd64 [upgradable from: 2.35-0ubuntu3.8] 6 | libc6/jammy-updates,jammy-security 2.35-0ubuntu3.10 amd64 [upgradable from: 2.35-0ubuntu3.8] 7 | libcap2/jammy-updates,jammy-security 1:2.44-1ubuntu0.22.04.2 amd64 [upgradable from: 1:2.44-1ubuntu0.22.04.1] 8 | libgnutls30/jammy-updates,jammy-security 3.7.3-4ubuntu1.6 amd64 [upgradable from: 3.7.3-4ubuntu1.5] 9 | libgssapi-krb5-2/jammy-updates,jammy-security 1.19.2-2ubuntu0.7 amd64 [upgradable from: 1.19.2-2ubuntu0.4] 10 | libk5crypto3/jammy-updates,jammy-security 1.19.2-2ubuntu0.7 amd64 [upgradable from: 1.19.2-2ubuntu0.4] 11 | libkrb5-3/jammy-updates,jammy-security 1.19.2-2ubuntu0.7 amd64 [upgradable from: 1.19.2-2ubuntu0.4] 12 | libkrb5support0/jammy-updates,jammy-security 1.19.2-2ubuntu0.7 amd64 [upgradable from: 1.19.2-2ubuntu0.4] 13 | libpam-modules-bin/jammy-updates 1.4.0-11ubuntu2.5 amd64 [upgradable from: 1.4.0-11ubuntu2.4] 14 | libpam-modules/jammy-updates 1.4.0-11ubuntu2.5 amd64 [upgradable from: 1.4.0-11ubuntu2.4] 15 | libpam-runtime/jammy-updates 1.4.0-11ubuntu2.5 all [upgradable from: 1.4.0-11ubuntu2.4] 16 | libpam0g/jammy-updates 1.4.0-11ubuntu2.5 amd64 [upgradable from: 1.4.0-11ubuntu2.4] 17 | libseccomp2/jammy-updates 2.5.3-2ubuntu3~22.04.1 amd64 [upgradable from: 2.5.3-2ubuntu2] 18 | libssl3/jammy-updates,jammy-security 3.0.2-0ubuntu1.19 amd64 [upgradable from: 3.0.2-0ubuntu1.18] 19 | libsystemd0/jammy-updates 249.11-0ubuntu3.15 amd64 [upgradable from: 249.11-0ubuntu3.12] 20 | libtasn1-6/jammy-updates,jammy-security 4.18.0-4ubuntu0.1 amd64 [upgradable from: 4.18.0-4build1] 21 | libudev1/jammy-updates 249.11-0ubuntu3.15 amd64 [upgradable from: 249.11-0ubuntu3.12] 22 | perl-base/jammy-updates,jammy-security 5.34.0-3ubuntu1.4 amd64 [upgradable from: 5.34.0-3ubuntu1.3] 23 | -------------------------------------------------------------------------------- /testing/fixtures/snap/info-core.txt: -------------------------------------------------------------------------------- 1 | name: core 2 | summary: Snap runtime environment 3 | publisher: Canonical** 4 | store-url: https://snapcraft.io/core 5 | license: unset 6 | description: | 7 | Base snaps are a specific type of snap that include libraries and 8 | dependencies common to many applications. They provide a consistent and 9 | reliable execution environment for the snap packages that use them. 10 | 11 | This core base snap additionally includes the snapd binaries, which in 12 | later releases are installed separately as the snapd snap. For more details 13 | on the snapd snap, see https://snapcraft.io/snapd. 14 | 15 | The core base snap provides a runtime environment based on Ubuntu 16.04 ESM 16 | (Xenial Xerus). 17 | 18 | Other Ubuntu environment base snaps include: 19 | - Core 18: 20 | - Core 20: 21 | - Core 22: 22 | - Core 24: 23 | 24 | **Using a base snap** 25 | 26 | Base snaps are installed automatically when a snap package requires them. 27 | Only one of each type of base snap is ever installed. 28 | 29 | Manually removing a base snap may affect the stability of your system. 30 | 31 | **Building snaps with core** 32 | 33 | Snap developers can use this base in their own snaps by adding the 34 | following to the snap's snapcraft.yaml: 35 | 36 | base: core 37 | 38 | **Additional Information*** 39 | 40 | For more details, and guidance on using base snaps, see our documentation: 41 | 42 | type: core 43 | snap-id: 99T7MUlRhtI3U0QFgl5mXXESAiSwt776 44 | tracking: latest/stable 45 | refresh-date: 23 days ago, at 18:37 CST 46 | channels: 47 | latest/stable: 16-2.61.4-20241002 2025-05-09 (17210) 109MB - 48 | latest/candidate: 16-2.61.4-20241002 2025-05-06 (17210) 109MB - 49 | latest/beta: 16-2.61.4-20250508 2025-05-08 (17212) 109MB - 50 | latest/edge: 16-2.61.4-20250529 2025-05-29 (17230) 109MB - 51 | installed: 16-2.61.4-20241002 (17210) 109MB core 52 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci-lint configuration 2 | # https://golangci-lint.run/usage/configuration/ 3 | 4 | run: 5 | timeout: 5m 6 | tests: true 7 | exclude-dirs: 8 | - vendor 9 | - testdata 10 | - testing 11 | 12 | linters: 13 | enable: 14 | # Default linters 15 | - errcheck 16 | - gosimple 17 | - govet 18 | - ineffassign 19 | - staticcheck 20 | - typecheck 21 | - unused 22 | # Additional linters 23 | - gofmt 24 | - goimports 25 | - misspell 26 | - unconvert 27 | - gocyclo 28 | - goprintffuncname 29 | - gosec 30 | - nakedret 31 | - noctx 32 | - nolintlint 33 | - predeclared 34 | - thelper 35 | - tparallel 36 | - unparam 37 | 38 | disable: 39 | - depguard 40 | - dogsled 41 | - dupl 42 | - funlen 43 | - gochecknoinits 44 | - goconst 45 | - gocritic 46 | - gocognit 47 | - lll 48 | - nestif 49 | - testpackage 50 | - wrapcheck 51 | - exhaustive 52 | - exhaustruct 53 | - nlreturn 54 | 55 | linters-settings: 56 | gofmt: 57 | simplify: true 58 | goimports: 59 | local-prefixes: github.com/bluet/syspkg 60 | govet: 61 | enable: 62 | - shadow 63 | misspell: 64 | locale: US 65 | gocyclo: 66 | min-complexity: 20 67 | 68 | issues: 69 | exclude-rules: 70 | # Exclude some linters from running on tests files 71 | - path: _test\.go 72 | linters: 73 | - gosec 74 | - gocyclo 75 | 76 | # Exclude shadow warning for common patterns 77 | - linters: 78 | - govet 79 | text: "shadow: declaration of \"err\"" 80 | 81 | # Exclude misspelling in specific cases 82 | - linters: 83 | - misspell 84 | text: "cancelled" 85 | 86 | # Exclude unnecessary conversion in utils (string([]byte) is explicit) 87 | - path: utils\.go 88 | linters: 89 | - unconvert 90 | text: "unnecessary conversion" 91 | 92 | # Exclude high cyclomatic complexity for main function 93 | - path: cmd/syspkg/main\.go 94 | linters: 95 | - gocyclo 96 | text: "cyclomatic complexity.*of func `main`" 97 | 98 | # Maximum issues count per one linter 99 | max-issues-per-linter: 50 100 | 101 | # Maximum count of issues with the same text 102 | max-same-issues: 10 103 | -------------------------------------------------------------------------------- /osinfo/README.md: -------------------------------------------------------------------------------- 1 | # Go SysPkg: OSInfo 2 | 3 | Go SysPkg is a library that provides system package management and operating system information. This documentation focuses on the `osinfo` package, which allows you to obtain information about the operating system. 4 | 5 | ## Installation 6 | 7 | To install the `github.com/bluet/syspkg/osinfo` package, use the following command: 8 | 9 | ```bash 10 | go get github.com/bluet/syspkg/osinfo 11 | ``` 12 | 13 | ## Usage 14 | 15 | To use the `osinfo` package, you need to import it into your Go program: 16 | 17 | ```go 18 | import ( 19 | "github.com/bluet/syspkg/osinfo" 20 | ) 21 | ``` 22 | 23 | ### GetOSInfo 24 | 25 | The primary function in the `osinfo` package is `GetOSInfo()`. It returns an `*OSInfo` struct, which contains information about the operating system, including: 26 | 27 | - Name 28 | - Distribution 29 | - Version 30 | - Architecture 31 | 32 | Here's an example of how to use the `GetOSInfo()` function: 33 | 34 | ```go 35 | package main 36 | 37 | import ( 38 | "fmt" 39 | "github.com/bluet/syspkg/osinfo" 40 | ) 41 | 42 | func main() { 43 | osInfo, err := osinfo.GetOSInfo() 44 | if err != nil { 45 | fmt.Println("Error getting OS info:", err) 46 | return 47 | } 48 | 49 | fmt.Println("Name:", osInfo.Name) 50 | fmt.Println("Distribution:", osInfo.Distribution) 51 | fmt.Println("Version:", osInfo.Version) 52 | fmt.Println("Architecture:", osInfo.Arch) 53 | } 54 | ``` 55 | 56 | In this example, the `GetOSInfo()` function is called, and the returned `*OSInfo` struct is used to print information about the operating system. If there is an error, it is handled and displayed to the user. 57 | 58 | Run the example with the following command: 59 | 60 | ```bash 61 | go run main.go 62 | ``` 63 | 64 | You should see output similar to the following: 65 | 66 | ```text 67 | Name: linux 68 | Distribution: Ubuntu 69 | Version: 20.04 70 | Architecture: amd64 71 | ``` 72 | 73 | The output will vary depending on the system you are running the program on. 74 | 75 | ## Summary 76 | 77 | The `osinfo` package of the Go SysPkg library provides a simple and efficient way to obtain information about the operating system. The `GetOSInfo()` function is easy to use and returns a struct containing all the necessary information about the system's OS. This package can be useful for programs that need to detect the OS and perform tasks specific to a particular OS, distribution, or version. 78 | -------------------------------------------------------------------------------- /manager/apt/EXIT_CODES.md: -------------------------------------------------------------------------------- 1 | # APT Exit Codes 2 | 3 | This document details exit codes for APT (Advanced Package Tool) commands used in syspkg. 4 | 5 | ## Overview 6 | 7 | APT uses a simple binary exit code system: 8 | - **0**: Success 9 | - **100**: Any error occurred 10 | - **1**: Special case (apt run without options) 11 | 12 | ## Source Reference 13 | 14 | From Debian APT source code (`cmdline/apt.cc`): 15 | - Returns 100 on failure, 0 on success 16 | - Multiple `exit(100)` statements throughout codebase for various errors 17 | - No specific exit codes for different error types 18 | 19 | ## Verified Behavior 20 | 21 | ### apt search 22 | ```bash 23 | # No packages found - returns SUCCESS 24 | $ apt search nonexistentpackage123456 25 | $ echo $? # Returns: 0 26 | 27 | # Invalid option - returns ERROR 28 | $ apt search --invalid-option 29 | $ echo $? # Returns: 100 30 | ``` 31 | 32 | ### apt-get commands 33 | - `apt-get update` failures: **100** 34 | - `apt-get install` failures: **100** 35 | - Repository errors: **100** 36 | - Network errors: **100** 37 | 38 | ## Current syspkg Bug 39 | 40 | **BUG**: Our code incorrectly assumes: 41 | ```go 42 | // WRONG: APT search does NOT return 100 for "no packages found" 43 | if exitError.ExitCode() == 100 { 44 | // No packages found, return empty list 45 | return []manager.PackageInfo{}, nil 46 | } 47 | ``` 48 | 49 | **Reality**: 50 | - APT search returns **0** when no packages found 51 | - APT search returns **100** only on actual errors (invalid options, etc.) 52 | 53 | ## dpkg Exit Codes 54 | 55 | APT also uses `dpkg-query` which has different exit codes: 56 | 57 | - **0**: Success 58 | - **1**: Package not found (normal condition) 59 | - **2**: Serious error 60 | 61 | Example: 62 | ```go 63 | // Current code in utils.go handles this correctly: 64 | if exitErr.ExitCode() != 1 && !strings.Contains(string(out), "no packages found matching") { 65 | return nil, fmt.Errorf("command failed with output: %s", string(out)) 66 | } 67 | ``` 68 | 69 | ## Recommendations 70 | 71 | 1. **Fix search exit code handling**: Remove incorrect 100 handling 72 | 2. **Test each command**: APT uses different tools with different codes 73 | 3. **No generic helpers**: APT behavior is unique 74 | 75 | ## Testing Commands 76 | 77 | ```bash 78 | # Test in Ubuntu container 79 | docker run --rm ubuntu:22.04 bash -c 'apt search test; echo $?' 80 | docker run --rm ubuntu:22.04 bash -c 'apt search --invalid; echo $?' 81 | ``` 82 | -------------------------------------------------------------------------------- /testing/capture-fixtures.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to capture real package manager outputs for test fixtures 3 | 4 | set -e 5 | 6 | FIXTURE_DIR="testing/fixtures" 7 | mkdir -p "$FIXTURE_DIR"/{apt,snap,flatpak,dnf,apk} 8 | 9 | echo "Capturing package manager outputs for test fixtures..." 10 | 11 | # APT (if available) 12 | if command -v apt &> /dev/null; then 13 | echo "Capturing APT outputs..." 14 | apt search vim 2>/dev/null | head -50 > "$FIXTURE_DIR/apt/search-vim.txt" || true 15 | apt show vim 2>/dev/null > "$FIXTURE_DIR/apt/show-vim.txt" || true 16 | dpkg -l | head -20 > "$FIXTURE_DIR/apt/list-installed.txt" || true 17 | apt list --upgradable 2>/dev/null | head -20 > "$FIXTURE_DIR/apt/list-upgradable.txt" || true 18 | fi 19 | 20 | # SNAP (if available) - requires real system with snapd running 21 | if command -v snap &> /dev/null && systemctl is-active snapd &>/dev/null; then 22 | echo "Capturing SNAP outputs..." 23 | snap find vim 2>/dev/null | head -20 > "$FIXTURE_DIR/snap/find-vim.txt" || true 24 | snap list 2>/dev/null > "$FIXTURE_DIR/snap/list.txt" || true 25 | snap info core 2>/dev/null > "$FIXTURE_DIR/snap/info-core.txt" || true 26 | else 27 | echo "SNAP not available or snapd not running - using Docker for mock data" 28 | # We can't get real snap outputs from Docker, so we'll document sample outputs 29 | cat > "$FIXTURE_DIR/snap/find-vim.txt" << 'EOF' 30 | Name Version Publisher Notes Summary 31 | vim-editor 8.2.3995 jonathonf - Vi IMproved - enhanced vi editor 32 | nvim 0.7.2 neovim - Vim-fork focused on extensibility 33 | EOF 34 | 35 | cat > "$FIXTURE_DIR/snap/list.txt" << 'EOF' 36 | Name Version Rev Tracking Publisher Notes 37 | core20 20230801 2015 latest/stable canonical✓ base 38 | core22 20230801 864 latest/stable canonical✓ base 39 | snapd 2.60.3 20290 latest/stable canonical✓ snapd 40 | EOF 41 | fi 42 | 43 | # FLATPAK (if available) 44 | if command -v flatpak &> /dev/null; then 45 | echo "Capturing FLATPAK outputs..." 46 | flatpak search vim 2>/dev/null | head -20 > "$FIXTURE_DIR/flatpak/search-vim.txt" || true 47 | flatpak list 2>/dev/null > "$FIXTURE_DIR/flatpak/list.txt" || true 48 | fi 49 | 50 | echo "Fixture capture complete!" 51 | echo "You can now use these files in your tests:" 52 | find "$FIXTURE_DIR" -type f -name "*.txt" | sort 53 | -------------------------------------------------------------------------------- /manager/security.go: -------------------------------------------------------------------------------- 1 | // Package manager provides security utilities for package manager operations 2 | package manager 3 | 4 | import ( 5 | "errors" 6 | "regexp" 7 | ) 8 | 9 | // packageNameRegex defines the allowed pattern for package names 10 | // This pattern allows: 11 | // - Letters (a-z, A-Z) 12 | // - Numbers (0-9) 13 | // - Dash/hyphen (-) 14 | // - Underscore (_) 15 | // - Period/dot (.) 16 | // - Plus sign (+) 17 | // - Colon (:) for architecture specifiers (e.g., package:amd64) 18 | // - Forward slash (/) for repository specifiers (e.g., repo/package) 19 | // The pattern requires at least one valid character 20 | var packageNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-_.+:/]+$`) 21 | 22 | // ErrInvalidPackageName is returned when a package name contains invalid characters 23 | var ErrInvalidPackageName = errors.New("invalid package name: contains potentially dangerous characters") 24 | 25 | // ValidatePackageName validates that a package name only contains safe characters 26 | // to prevent command injection attacks. 27 | // 28 | // Valid package names may contain: 29 | // - Alphanumeric characters (a-z, A-Z, 0-9) 30 | // - Dash/hyphen (-) 31 | // - Underscore (_) 32 | // - Period/dot (.) 33 | // - Plus sign (+) 34 | // - Colon (:) for architecture specifiers 35 | // - Forward slash (/) for repository specifiers 36 | // 37 | // This function rejects any package names containing: 38 | // - Shell metacharacters (;, |, &, $, `, \, ", ', <, >, (, ), {, }, [, ], *, ?, ~) 39 | // - Whitespace characters 40 | // - Control characters 41 | // - Null bytes 42 | // 43 | // Example valid names: 44 | // - "vim" 45 | // - "libssl1.1" 46 | // - "gcc-9-base" 47 | // - "python3.8" 48 | // - "package:amd64" 49 | // - "repo/package" 50 | // 51 | // Example invalid names: 52 | // - "package; rm -rf /" 53 | // - "package && malicious-command" 54 | // - "package`evil`" 55 | // - "package$(bad)" 56 | func ValidatePackageName(name string) error { 57 | // Check for empty string 58 | if name == "" { 59 | return errors.New("package name cannot be empty") 60 | } 61 | 62 | // Check length limit (most package managers have reasonable limits) 63 | if len(name) > 255 { 64 | return errors.New("package name too long (max 255 characters)") 65 | } 66 | 67 | // Validate against regex pattern 68 | if !packageNameRegex.MatchString(name) { 69 | return ErrInvalidPackageName 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // ValidatePackageNames validates multiple package names 76 | func ValidatePackageNames(names []string) error { 77 | for _, name := range names { 78 | if err := ValidatePackageName(name); err != nil { 79 | return err 80 | } 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /docs/EXIT_CODES.md: -------------------------------------------------------------------------------- 1 | # Package Manager Exit Codes Overview 2 | 3 | This document provides a high-level overview of exit code behavior across package managers. 4 | **For detailed information, see the EXIT_CODES.md file in each package manager directory.** 5 | 6 | ## 📖 Related Documentation 7 | 8 | - **[../README.md](../README.md)** - Project overview 9 | - **[ARCHITECTURE.md](ARCHITECTURE.md)** - Technical design and interfaces 10 | - **[../CONTRIBUTING.md](../CONTRIBUTING.md)** - Development workflow and testing guide 11 | - **[../manager/apt/EXIT_CODES.md](../manager/apt/EXIT_CODES.md)** - APT-specific exit codes 12 | - **[../manager/yum/EXIT_CODES.md](../manager/yum/EXIT_CODES.md)** - YUM-specific exit codes 13 | 14 | ## Critical Insights 15 | 16 | ### Exit Codes Are NOT Consistent 17 | | Package Manager | "No Error" | "Updates Available" | "No Packages Found" | "Usage Error" | 18 | |----------------|------------|-------------------|-------------------|---------------| 19 | | **APT** | 0 | N/A | 0 (success) | 100 | 20 | | **YUM** | 0 | 100 (success!) | 0 (success) | 1 | 21 | | **Snap** | 0 | N/A | 0 (success) | 64 | 22 | | **Flatpak** | 0 | N/A | 1 (needs verify) | 1 | 23 | 24 | ### Dangerous Assumptions 25 | 26 | ⚠️ **Same exit code, opposite meanings:** 27 | - APT: 100 = error 28 | - YUM: 100 = success (updates available) 29 | 30 | ⚠️ **Our code has bugs:** 31 | - APT: Assumes 100 = "no packages found" (WRONG - it's an error!) 32 | - Snap: Assumes 64 = "no packages found" (WRONG - it's usage error!) 33 | 34 | ## Key Principles 35 | 36 | 1. **Never use generic exit code helpers** - each PM is unique 37 | 2. **Test actual behavior** - documentation can be wrong 38 | 3. **Each PM uses different tools** - APT uses both `apt` and `dpkg-query` 39 | 4. **Verify through testing** - not just documentation 40 | 41 | ## Documentation Structure 42 | 43 | - **Central overview**: This file (cross-PM comparison) 44 | - **Detailed docs**: `manager/{pm}/EXIT_CODES.md` (PM-specific behavior) 45 | 46 | ## For Package Manager Implementation 47 | 48 | When implementing Option C (CommandBuilder), each package manager must: 49 | 1. Handle its own exit codes specifically 50 | 2. Document actual behavior (not assumptions) 51 | 3. Test thoroughly in real environments 52 | 4. Never rely on generic patterns 53 | 54 | ## Bugs to Fix 55 | 56 | 1. **APT**: Remove incorrect handling of exit code 100 as "no packages found" 57 | 2. **Snap**: Remove incorrect handling of exit code 64 as "no packages found" 58 | 3. **All PMs**: Verify and document actual exit code behavior 59 | -------------------------------------------------------------------------------- /testing/fixtures/yum/install-multiple-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:58 ago on Sat May 31 07:53:22 2025. 2 | Package curl-7.61.1-34.el8_10.3.x86_64 is already installed. 3 | Dependencies resolved. 4 | ================================================================================ 5 | Package Arch Version Repository Size 6 | ================================================================================ 7 | Installing: 8 | wget x86_64 1.19.5-12.el8_10 appstream 733 k 9 | Installing dependencies: 10 | libmetalink x86_64 0.1.3-7.el8 baseos 31 k 11 | libpsl x86_64 0.20.2-6.el8 baseos 60 k 12 | publicsuffix-list-dafsa noarch 20180723-1.el8 baseos 55 k 13 | 14 | Transaction Summary 15 | ================================================================================ 16 | Install 4 Packages 17 | 18 | Total download size: 879 k 19 | Installed size: 3.0 M 20 | Downloading Packages: 21 | (1/4): libmetalink-0.1.3-7.el8.x86_64.rpm 264 kB/s | 31 kB 00:00 22 | (2/4): libpsl-0.20.2-6.el8.x86_64.rpm 493 kB/s | 60 kB 00:00 23 | (3/4): publicsuffix-list-dafsa-20180723-1.el8.n 1.2 MB/s | 55 kB 00:00 24 | (4/4): wget-1.19.5-12.el8_10.x86_64.rpm 1.1 MB/s | 733 kB 00:00 25 | -------------------------------------------------------------------------------- 26 | Total 472 kB/s | 879 kB 00:01 27 | Running transaction check 28 | Transaction check succeeded. 29 | Running transaction test 30 | Transaction test succeeded. 31 | Running transaction 32 | Preparing : 1/1 33 | Installing : publicsuffix-list-dafsa-20180723-1.el8.noarch 1/4 34 | Installing : libpsl-0.20.2-6.el8.x86_64 2/4 35 | Installing : libmetalink-0.1.3-7.el8.x86_64 3/4 36 | Installing : wget-1.19.5-12.el8_10.x86_64 4/4 37 | Running scriptlet: wget-1.19.5-12.el8_10.x86_64 4/4 38 | Verifying : wget-1.19.5-12.el8_10.x86_64 1/4 39 | Verifying : libmetalink-0.1.3-7.el8.x86_64 2/4 40 | Verifying : libpsl-0.20.2-6.el8.x86_64 3/4 41 | Verifying : publicsuffix-list-dafsa-20180723-1.el8.noarch 4/4 42 | 43 | Installed: 44 | libmetalink-0.1.3-7.el8.x86_64 libpsl-0.20.2-6.el8.x86_64 45 | publicsuffix-list-dafsa-20180723-1.el8.noarch wget-1.19.5-12.el8_10.x86_64 46 | 47 | Complete! 48 | -------------------------------------------------------------------------------- /manager/yum/EXIT_CODES.md: -------------------------------------------------------------------------------- 1 | # YUM Exit Codes 2 | 3 | This document details exit codes for YUM (Yellowdog Updater Modified) commands used in syspkg. 4 | 5 | ## Overview 6 | 7 | YUM uses different exit codes depending on the operation: 8 | - **0**: Success (general operations) 9 | - **1**: Error occurred 10 | - **100**: Special meaning for `check-update` (updates available - SUCCESS!) 11 | 12 | ## Source Reference 13 | 14 | From Red Hat documentation: 15 | - "yum check-update exit code 100" is documented as "packages available for update" 16 | - Exit code 100 is **not an error** for check-update command 17 | - Other commands follow standard 0=success, 1=error pattern 18 | 19 | ## Verified Behavior 20 | 21 | ### yum check-update (Special Case) 22 | ```bash 23 | # No updates available 24 | $ yum check-update 25 | $ echo $? # Returns: 0 26 | 27 | # Updates available - THIS IS SUCCESS! 28 | $ yum check-update 29 | [... lists available updates ...] 30 | $ echo $? # Returns: 100 31 | 32 | # Error occurred 33 | $ yum check-update (with network issues) 34 | $ echo $? # Returns: 1 35 | ``` 36 | 37 | **Critical**: Exit code 100 means **SUCCESS** for check-update! 38 | 39 | ### Other YUM commands 40 | - `yum search`: 0=success, 1=error 41 | - `yum install`: 0=success, 1=error 42 | - `yum info`: 0=success, 1=error 43 | 44 | ## Current syspkg Implementation 45 | 46 | **CORRECT**: Our code properly handles check-update: 47 | ```go 48 | // YUM check-update returns exit code 100 when updates are available 49 | if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 100 { 50 | // Exit code 100 means updates are available, continue parsing 51 | } else { 52 | // Other exit codes indicate real errors 53 | return nil, err 54 | } 55 | ``` 56 | 57 | ## Key Differences from APT 58 | 59 | ⚠️ **CRITICAL**: Same exit code, opposite meaning! 60 | - **APT**: 100 = error 61 | - **YUM**: 100 = success (for check-update) 62 | 63 | This is why generic exit code helpers would be dangerous! 64 | 65 | ## rpm Integration 66 | 67 | YUM also uses `rpm` commands for status detection: 68 | - `rpm -q package`: 0=installed, 1=not installed 69 | - Used in `Find()` method for accurate status detection 70 | 71 | ## Testing Commands 72 | 73 | ```bash 74 | # Test in Rocky Linux container 75 | docker run --rm rockylinux:8 bash -c 'yum check-update; echo $?' 76 | docker run --rm rockylinux:8 bash -c 'yum search test; echo $?' 77 | docker run --rm rockylinux:8 bash -c 'rpm -q bash; echo $?' 78 | ``` 79 | 80 | ## Recommendations 81 | 82 | 1. **Document special behaviors**: check-update is unique 83 | 2. **Never generalize**: YUM exit codes are command-specific 84 | 3. **Test thoroughly**: Different containers have different update availability 85 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # CI/CD Workflows 2 | 3 | This directory contains GitHub Actions workflows for the go-syspkg project. 4 | 5 | ## Workflows 6 | 7 | ### 1. Test and Coverage (`test-and-coverage.yml`) 8 | Runs comprehensive tests with coverage reporting: 9 | - **Go Versions**: 1.23, 1.24 (project requires Go 1.23+) 10 | - **Platform**: Ubuntu Latest 11 | - **Coverage**: Uploads test coverage to Codecov 12 | - **Race Detection**: Runs tests with race detector enabled 13 | 14 | ### 2. Lint and Format (`lint-and-format.yml`) 15 | Ensures code quality and formatting standards: 16 | - **gofmt**: Checks code formatting 17 | - **go vet**: Reports suspicious constructs 18 | - **golangci-lint**: Comprehensive linting with multiple linters 19 | - **go mod tidy**: Ensures go.mod and go.sum are up to date 20 | 21 | ### 3. Build (`build.yml`) 22 | Verifies code builds across Go versions: 23 | - **Go Versions**: 1.23, 1.24 24 | - **Build Targets**: All packages and CLI binary 25 | - **Platform**: Ubuntu Latest 26 | 27 | ### 4. Release Binaries (`release-binaries.yml`) 28 | Creates cross-platform release binaries: 29 | - **Platforms**: Linux, Windows, Darwin 30 | - **Architectures**: amd64, arm64, 386 (where supported) 31 | - **Go Version**: 1.24 32 | - **Trigger**: On GitHub releases 33 | 34 | ## Local Development 35 | 36 | ### Running Checks Locally 37 | ```bash 38 | # Format code 39 | make format 40 | 41 | # Check formatting and run linters 42 | make check 43 | 44 | # Run specific checks 45 | gofmt -l . # List files that need formatting 46 | go vet ./... # Run go vet 47 | golangci-lint run # Run all linters 48 | ``` 49 | 50 | ### Pre-commit Hooks 51 | Install pre-commit to run checks automatically before commits: 52 | ```bash 53 | pre-commit install 54 | ``` 55 | 56 | Pre-commit hooks include: 57 | - File hygiene (trailing whitespace, EOF, merge conflicts) 58 | - Go tools (gofmt, goimports, go vet, go mod tidy, golangci-lint) 59 | - Build verification (go build, go mod verify) 60 | - Security-focused using local system tools only 61 | 62 | ## Configuration Files 63 | 64 | - `.golangci.yml`: Configures golangci-lint with enabled/disabled linters 65 | - `.pre-commit-config.yaml`: Defines pre-commit hooks 66 | - `Makefile`: Contains format and check targets 67 | 68 | ## Adding New Checks 69 | 70 | To add new linting rules: 71 | 1. Update `.golangci.yml` to enable/disable linters 72 | 2. Update the `lint-and-format.yml` workflow if needed 73 | 3. Test locally with `make check` 74 | 75 | ## Project Standards 76 | 77 | - **License**: Apache License 2.0 (patent protection and enterprise clarity) 78 | - **Go Version**: 1.23+ required, CI tests with 1.23 and 1.24 79 | - **Code Quality**: Enforced via pre-commit hooks and CI workflows 80 | - **Security**: Local system tools used in pre-commit for maximum security 81 | -------------------------------------------------------------------------------- /manager/snap/EXIT_CODES.md: -------------------------------------------------------------------------------- 1 | # Snap Exit Codes 2 | 3 | This document details exit codes for Snap commands used in syspkg. 4 | 5 | ## Overview 6 | 7 | Snap follows Unix standard exit codes: 8 | - **0**: Success 9 | - **1**: General error 10 | - **64**: Command usage error (EX_USAGE from sysexits.h) 11 | 12 | ## Source Reference 13 | 14 | From Unix sysexits.h: 15 | ```c 16 | #define EX_USAGE 64 /* command line usage error */ 17 | ``` 18 | 19 | Exit code 64 indicates incorrect command syntax or invalid arguments. 20 | 21 | ## Verified Behavior 22 | 23 | ### snap search 24 | Testing is difficult in Docker due to snapd service requirements, but based on Unix standards: 25 | 26 | ```bash 27 | # Valid search (expected) 28 | $ snap search vim 29 | $ echo $? # Should return: 0 30 | 31 | # Invalid option (expected) 32 | $ snap search --invalid-option 33 | $ echo $? # Should return: 64 (usage error) 34 | 35 | # No packages found (expected) 36 | $ snap search nonexistentpackage123456 37 | $ echo $? # Should return: 0 (success, empty results) 38 | ``` 39 | 40 | ## Current syspkg Bug 41 | 42 | **BUG**: Our code incorrectly interprets exit code 64: 43 | ```go 44 | // WRONG: Exit code 64 is NOT "no packages found" 45 | if exitError.ExitCode() == 64 { 46 | // No packages found, return empty list // ← This is wrong! 47 | return []manager.PackageInfo{}, nil 48 | } 49 | ``` 50 | 51 | **Reality**: 52 | - Exit code 64 = command usage error (invalid syntax/options) 53 | - Exit code 0 = success (including when no packages found) 54 | - Exit code 1 = general error 55 | 56 | ## Unix Exit Code Standards 57 | 58 | Snap follows standard Unix exit codes (sysexits.h): 59 | - **64 (EX_USAGE)**: Command line usage error 60 | - **65 (EX_DATAERR)**: Data format error 61 | - **66 (EX_NOINPUT)**: Cannot open input 62 | - **67 (EX_NOUSER)**: Addressee unknown 63 | - **68 (EX_NOHOST)**: Host name unknown 64 | - **69 (EX_UNAVAILABLE)**: Service unavailable 65 | - **70 (EX_SOFTWARE)**: Internal software error 66 | - **71 (EX_OSERR)**: System error 67 | - **72 (EX_OSFILE)**: Critical OS file missing 68 | - **73 (EX_CANTCREAT)**: Can't create output file 69 | - **74 (EX_IOERR)**: Input/output error 70 | - **75 (EX_TEMPFAIL)**: Temporary failure 71 | - **76 (EX_PROTOCOL)**: Remote error in protocol 72 | - **77 (EX_NOPERM)**: Permission denied 73 | - **78 (EX_CONFIG)**: Configuration error 74 | 75 | ## Recommendations 76 | 77 | 1. **Fix exit code 64 handling**: Remove incorrect assumption 78 | 2. **Handle usage errors properly**: Exit code 64 should be treated as command error 79 | 3. **Test with real snap environment**: Docker testing is unreliable for snap 80 | 81 | ## Testing Commands 82 | 83 | ```bash 84 | # Test on system with snapd (not Docker) 85 | snap search test; echo $? 86 | snap search --invalid-option; echo $? 87 | snap --invalid-command; echo $? 88 | ``` 89 | 90 | **Note**: Snap testing in Docker containers is unreliable due to systemd/snapd service dependencies. 91 | -------------------------------------------------------------------------------- /manager/command_runner_env_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestCommandRunnerEnvironmentHandling(t *testing.T) { 10 | t.Run("DefaultCommandRunner prepends LC_ALL=C", func(t *testing.T) { 11 | runner := NewDefaultCommandRunner() 12 | 13 | // Test that LC_ALL=C is prepended automatically using 'env' command 14 | // This is more reliable than echo "$LC_ALL" across different systems 15 | output, err := runner.Run("env") 16 | if err != nil { 17 | t.Fatalf("Failed to run 'env' command: %v", err) 18 | } 19 | 20 | // Verify that LC_ALL=C appears in the environment 21 | envOutput := string(output) 22 | if !strings.Contains(envOutput, "LC_ALL=C") { 23 | t.Errorf("Expected LC_ALL=C in environment output, but not found. Output: %s", envOutput) 24 | } 25 | 26 | t.Log("✅ Verified: DefaultCommandRunner automatically prepends LC_ALL=C") 27 | }) 28 | 29 | t.Run("MockCommandRunner tracks environment variables", func(t *testing.T) { 30 | mock := NewMockCommandRunner() 31 | 32 | // Add a mocked command 33 | mock.AddCommand("apt", []string{"update"}, []byte("success"), nil) 34 | 35 | // Test RunContext with environment variables 36 | ctx := context.Background() 37 | _, err := mock.RunContext(ctx, "apt", []string{"update"}, "DEBIAN_FRONTEND=noninteractive") 38 | if err != nil { 39 | t.Errorf("Unexpected error: %v", err) 40 | } 41 | 42 | // Verify environment was tracked 43 | env := mock.GetEnvForCommand("apt", []string{"update"}) 44 | if len(env) != 1 || env[0] != "DEBIAN_FRONTEND=noninteractive" { 45 | t.Errorf("Expected env [DEBIAN_FRONTEND=noninteractive], got %v", env) 46 | } 47 | }) 48 | 49 | t.Run("MockCommandRunner tracks interactive environment", func(t *testing.T) { 50 | mock := NewMockCommandRunner() 51 | 52 | // Test RunInteractive with environment variables 53 | ctx := context.Background() 54 | err := mock.RunInteractive(ctx, "yum", []string{"install", "vim"}, "LANG=en_US.UTF-8") 55 | if err != nil { 56 | t.Errorf("Unexpected error: %v", err) 57 | } 58 | 59 | // Verify environment was tracked 60 | env := mock.GetEnvForCommand("yum", []string{"install", "vim"}) 61 | if len(env) != 1 || env[0] != "LANG=en_US.UTF-8" { 62 | t.Errorf("Expected env [LANG=en_US.UTF-8], got %v", env) 63 | } 64 | 65 | // Verify it was marked as interactive 66 | if !mock.WasInteractiveCalled("yum", []string{"install", "vim"}) { 67 | t.Error("Expected command to be marked as interactive") 68 | } 69 | }) 70 | 71 | t.Run("Empty environment handling", func(t *testing.T) { 72 | mock := NewMockCommandRunner() 73 | mock.AddCommand("ls", []string{}, []byte("file1\nfile2"), nil) 74 | 75 | // Call without environment variables 76 | ctx := context.Background() 77 | _, err := mock.RunContext(ctx, "ls", []string{}) 78 | if err != nil { 79 | t.Errorf("Unexpected error: %v", err) 80 | } 81 | 82 | // Verify empty environment was tracked 83 | env := mock.GetEnvForCommand("ls", []string{}) 84 | if len(env) != 0 { 85 | t.Errorf("Expected empty env, got %v", env) 86 | } 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /manager/flatpak/EXIT_CODES.md: -------------------------------------------------------------------------------- 1 | # Flatpak Exit Codes 2 | 3 | This document details exit codes for Flatpak commands used in syspkg. 4 | 5 | ## Overview 6 | 7 | Flatpak uses standard Unix exit codes with some special cases: 8 | - **0**: Success 9 | - **1**: General error (most common failure) 10 | - **42**: Special case for flatpak-builder (json unchanged) 11 | - **256**: Script execution failures 12 | 13 | ## Source Reference 14 | 15 | No comprehensive official documentation found. Information gathered from: 16 | - GitHub issues in flatpak/flatpak repository 17 | - Community reports and bug reports 18 | - Error messages in production systems 19 | 20 | ## Known Exit Codes 21 | 22 | ### Standard Codes 23 | - **0**: Success (operation completed successfully) 24 | - **1**: General error (installation failed, package not found, etc.) 25 | 26 | ### Special Codes 27 | - **42**: Used by flatpak-builder when "json is unchanged since last build" 28 | - **256**: Script failures (apply_extra script failed, ldconfig failed) 29 | 30 | ### Observed Behavior 31 | 32 | From GitHub issues and community reports: 33 | 34 | ```bash 35 | # Package not found (expected) 36 | $ flatpak search nonexistentpackage123456 37 | $ echo $? # Expected: 0 or 1 (not well documented) 38 | 39 | # Installation error (common) 40 | $ flatpak install nonexistent-app 41 | $ echo $? # Returns: 1 42 | 43 | # Script failures (ChromeOS specific) 44 | # "ldconfig failed, exit status 256" 45 | # "apply_extra script failed, exit status 256" 46 | ``` 47 | 48 | ## Current syspkg Implementation 49 | 50 | Our code assumes exit code 1 means "no packages found": 51 | ```go 52 | if exitError.ExitCode() == 1 { 53 | // No packages found, return empty list 54 | return []manager.PackageInfo{}, nil 55 | } 56 | ``` 57 | 58 | This may be correct, but needs verification through testing. 59 | 60 | ## Known Issues 61 | 62 | 1. **Missing Documentation**: Flatpak lacks comprehensive exit code documentation 63 | 2. **Platform Variations**: Different behavior on different systems (ChromeOS, Linux distributions) 64 | 3. **Script Dependencies**: Exit codes may vary based on external script failures 65 | 66 | ## Testing Challenges 67 | 68 | Flatpak testing can be complex due to: 69 | - Repository configuration requirements 70 | - Runtime dependencies 71 | - Platform-specific behaviors 72 | - Service dependencies 73 | 74 | ## Recommendations 75 | 76 | 1. **Test thoroughly**: Verify exit code 1 behavior for search 77 | 2. **Document actual behavior**: Don't assume based on other package managers 78 | 3. **Handle script errors**: Be prepared for exit code 256 scenarios 79 | 4. **Monitor GitHub issues**: Watch flatpak/flatpak for exit code discussions 80 | 81 | ## Testing Commands 82 | 83 | ```bash 84 | # Test in system with Flatpak configured 85 | flatpak search test; echo $? 86 | flatpak search nonexistentpackage123456; echo $? 87 | flatpak install --dry-run nonexistent; echo $? 88 | 89 | # Test in Docker (if Flatpak available) 90 | docker run --rm fedora:latest bash -c 'dnf install -y flatpak && flatpak search test; echo $?' 91 | ``` 92 | 93 | **Note**: Flatpak behavior may vary significantly between different Linux distributions and configurations. 94 | -------------------------------------------------------------------------------- /testing/fixtures/yum/install-vim-rocky8.txt: -------------------------------------------------------------------------------- 1 | Last metadata expiration check: 0:00:35 ago on Sat May 31 07:53:22 2025. 2 | Dependencies resolved. 3 | ================================================================================ 4 | Package Arch Version Repository Size 5 | ================================================================================ 6 | Installing: 7 | vim-enhanced x86_64 2:8.0.1763-19.el8_6.4 appstream 1.4 M 8 | Installing dependencies: 9 | gpm-libs x86_64 1.20.7-17.el8 appstream 38 k 10 | vim-common x86_64 2:8.0.1763-19.el8_6.4 appstream 6.3 M 11 | vim-filesystem noarch 2:8.0.1763-19.el8_6.4 appstream 49 k 12 | which x86_64 2.21-20.el8 baseos 49 k 13 | 14 | Transaction Summary 15 | ================================================================================ 16 | Install 5 Packages 17 | 18 | Total download size: 7.8 M 19 | Installed size: 30 M 20 | Downloading Packages: 21 | (1/5): gpm-libs-1.20.7-17.el8.x86_64.rpm 173 kB/s | 38 kB 00:00 22 | (2/5): vim-filesystem-8.0.1763-19.el8_6.4.noarc 389 kB/s | 49 kB 00:00 23 | (3/5): vim-enhanced-8.0.1763-19.el8_6.4.x86_64. 1.8 MB/s | 1.4 MB 00:00 24 | (4/5): which-2.21-20.el8.x86_64.rpm 106 kB/s | 49 kB 00:00 25 | (5/5): vim-common-8.0.1763-19.el8_6.4.x86_64.rp 3.7 MB/s | 6.3 MB 00:01 26 | -------------------------------------------------------------------------------- 27 | Total 2.7 MB/s | 7.8 MB 00:02 28 | Running transaction check 29 | Transaction check succeeded. 30 | Running transaction test 31 | Transaction test succeeded. 32 | Running transaction 33 | Preparing : 1/1 34 | Installing : which-2.21-20.el8.x86_64 1/5 35 | Installing : vim-filesystem-2:8.0.1763-19.el8_6.4.noarch 2/5 36 | Installing : vim-common-2:8.0.1763-19.el8_6.4.x86_64 3/5 37 | Installing : gpm-libs-1.20.7-17.el8.x86_64 4/5 38 | Running scriptlet: gpm-libs-1.20.7-17.el8.x86_64 4/5 39 | Installing : vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64 5/5 40 | Running scriptlet: vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64 5/5 41 | Running scriptlet: vim-common-2:8.0.1763-19.el8_6.4.x86_64 5/5 42 | Verifying : gpm-libs-1.20.7-17.el8.x86_64 1/5 43 | Verifying : vim-common-2:8.0.1763-19.el8_6.4.x86_64 2/5 44 | Verifying : vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64 3/5 45 | Verifying : vim-filesystem-2:8.0.1763-19.el8_6.4.noarch 4/5 46 | Verifying : which-2.21-20.el8.x86_64 5/5 47 | 48 | Installed: 49 | gpm-libs-1.20.7-17.el8.x86_64 50 | vim-common-2:8.0.1763-19.el8_6.4.x86_64 51 | vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64 52 | vim-filesystem-2:8.0.1763-19.el8_6.4.noarch 53 | which-2.21-20.el8.x86_64 54 | 55 | Complete! 56 | -------------------------------------------------------------------------------- /testing/testenv/testenv_test.go: -------------------------------------------------------------------------------- 1 | package testenv 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestGetTestEnvironment(t *testing.T) { 10 | env, err := GetTestEnvironment() 11 | if err != nil { 12 | t.Fatalf("Failed to get test environment: %v", err) 13 | } 14 | 15 | if env.OS == "" { 16 | t.Error("OS should not be empty") 17 | } 18 | 19 | if env.Distribution == "" { 20 | t.Error("Distribution should not be empty") 21 | } 22 | 23 | if len(env.AvailableManagers) == 0 { 24 | t.Error("Should have at least one available package manager") 25 | } 26 | 27 | t.Logf("Test Environment: OS=%s, Distribution=%s, Version=%s", 28 | env.OS, env.Distribution, env.Version) 29 | t.Logf("Available Package Managers: %v", env.AvailableManagers) 30 | t.Logf("Recommended Test Tags: %v", env.TestTags) 31 | t.Logf("In Container: %v", env.InContainer) 32 | } 33 | 34 | func TestShouldSkipTest(t *testing.T) { 35 | env, err := GetTestEnvironment() 36 | if err != nil { 37 | t.Fatalf("Failed to get test environment: %v", err) 38 | } 39 | 40 | // Test with a package manager that should be available 41 | if len(env.AvailableManagers) > 0 { 42 | available := env.AvailableManagers[0] 43 | skip, reason := env.ShouldSkipTest(available) 44 | if skip { 45 | t.Errorf("Should not skip test for available package manager %s: %s", available, reason) 46 | } 47 | } 48 | 49 | // Test with a package manager that should not be available 50 | skip, reason := env.ShouldSkipTest("nonexistent-pm") 51 | if !skip { 52 | t.Error("Should skip test for nonexistent package manager") 53 | } 54 | if reason == "" { 55 | t.Error("Should provide reason for skipping") 56 | } 57 | } 58 | 59 | func TestGetFixturePath(t *testing.T) { 60 | env, err := GetTestEnvironment() 61 | if err != nil { 62 | t.Fatalf("Failed to get test environment: %v", err) 63 | } 64 | 65 | path := env.GetFixturePath("apt", "search-vim") 66 | if path == "" { 67 | t.Error("Fixture path should not be empty") 68 | } 69 | 70 | t.Logf("Fixture path for apt search-vim: %s", path) 71 | } 72 | 73 | // TestVersionParsing tests the version parsing logic for RHEL-based distributions 74 | func TestVersionParsing(t *testing.T) { 75 | tests := []struct { 76 | version string 77 | expected string // expected package manager 78 | }{ 79 | {"8", "yum"}, 80 | {"8.5", "yum"}, 81 | {"8.5.2111", "yum"}, 82 | {"9", "dnf"}, 83 | {"9.0", "dnf"}, 84 | {"9.1.2022", "dnf"}, 85 | {"7.9", ""}, // No manager for version < 8 86 | } 87 | 88 | for _, tt := range tests { 89 | t.Run("version_"+tt.version, func(t *testing.T) { 90 | // Simulate version parsing logic 91 | versionParts := strings.Split(tt.version, ".") 92 | var manager string 93 | if len(versionParts) > 0 { 94 | majorVersion, err := strconv.Atoi(versionParts[0]) 95 | if err == nil { 96 | if majorVersion >= 9 { 97 | manager = "dnf" 98 | } else if majorVersion >= 8 { 99 | manager = "yum" 100 | } 101 | } 102 | } 103 | 104 | if manager != tt.expected { 105 | t.Errorf("For version %s, expected %s but got %s", tt.version, tt.expected, manager) 106 | } 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | repos: 3 | # Official pre-commit hooks (maintained by pre-commit organization) 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | exclude: ^testing/fixtures/ 9 | - id: end-of-file-fixer 10 | exclude: ^testing/fixtures/ 11 | - id: check-yaml 12 | - id: check-added-large-files 13 | - id: check-merge-conflict 14 | - id: check-case-conflict 15 | - id: check-json 16 | - id: check-toml 17 | - id: mixed-line-ending 18 | exclude: ^testing/fixtures/ 19 | 20 | # Google's official pre-commit tool hooks (optional - uncomment if needed) 21 | # - repo: https://github.com/google/pre-commit-tool-hooks 22 | # rev: v1.2.5 23 | # hooks: 24 | # - id: check-copyright 25 | # args: ['--copyright', 'Copyright YYYY'] 26 | 27 | # Go-specific hooks using local system tools (most secure approach) 28 | - repo: local 29 | hooks: 30 | - id: go-fmt 31 | name: go-fmt 32 | entry: gofmt 33 | args: [-w, -s] 34 | language: system 35 | files: \.go$ 36 | description: Run gofmt to format Go code 37 | 38 | - id: go-imports 39 | name: go-imports 40 | entry: goimports 41 | args: [-w, -local, github.com/bluet/syspkg] 42 | language: system 43 | files: \.go$ 44 | description: Run goimports to organize imports per Go Code Review guidelines 45 | 46 | - id: go-vet 47 | name: go-vet 48 | entry: go 49 | args: [vet, ./...] 50 | language: system 51 | files: \.go$ 52 | pass_filenames: false 53 | description: Run go vet to check for suspicious constructs 54 | 55 | - id: go-mod-tidy 56 | name: go-mod-tidy 57 | entry: go 58 | args: [mod, tidy] 59 | language: system 60 | files: ^go\.(mod|sum)$ 61 | pass_filenames: false 62 | description: Run go mod tidy to ensure dependencies are clean 63 | 64 | - id: golangci-lint 65 | name: golangci-lint 66 | entry: golangci-lint 67 | args: [run, --fix] 68 | language: system 69 | files: \.go$ 70 | pass_filenames: false 71 | description: Run golangci-lint with auto-fix 72 | 73 | # Uncomment for stricter pre-commit (may be slow): 74 | # - id: go-test 75 | # name: go-test 76 | # entry: go 77 | # args: [test, -v, ./...] 78 | # language: system 79 | # files: \.go$ 80 | # pass_filenames: false 81 | # description: Run go test to ensure code compiles and passes tests 82 | 83 | - id: go-build 84 | name: go-build 85 | entry: go 86 | args: [build, ./...] 87 | language: system 88 | files: \.go$ 89 | pass_filenames: false 90 | description: Check that packages can be built 91 | 92 | - id: go-mod-verify 93 | name: go-mod-verify 94 | entry: go 95 | args: [mod, verify] 96 | language: system 97 | files: ^go\.(mod|sum)$ 98 | pass_filenames: false 99 | description: Verify dependencies have expected content 100 | -------------------------------------------------------------------------------- /testing/fixtures/apt/list-installed.txt: -------------------------------------------------------------------------------- 1 | adduser 3.118ubuntu5 2 | apt 2.4.13 3 | base-files 12ubuntu4.7 4 | base-passwd 3.5.52build1 5 | bash 5.1-6ubuntu1.1 6 | bsdutils 1:2.37.2-4ubuntu3.4 7 | coreutils 8.32-4.1ubuntu1.2 8 | dash 0.5.11+git20210903+057cd650a4ed-3build1 9 | debconf 1.5.79ubuntu1 10 | debianutils 5.5-1ubuntu2 11 | diffutils 1:3.8-0ubuntu2 12 | dpkg 1.21.1ubuntu2.3 13 | e2fsprogs 1.46.5-2ubuntu1.2 14 | findutils 4.8.0-1ubuntu3 15 | gcc-12-base:amd64 12.3.0-1ubuntu1~22.04 16 | gpgv 2.2.27-3ubuntu2.1 17 | grep 3.7-1build1 18 | gzip 1.10-4ubuntu4.1 19 | hostname 3.23ubuntu2 20 | init-system-helpers 1.62 21 | libacl1:amd64 2.3.1-1 22 | libapt-pkg6.0:amd64 2.4.13 23 | libattr1:amd64 1:2.5.1-1build1 24 | libaudit-common 1:3.0.7-1build1 25 | libaudit1:amd64 1:3.0.7-1build1 26 | libblkid1:amd64 2.37.2-4ubuntu3.4 27 | libbz2-1.0:amd64 1.0.8-5build1 28 | libc-bin 2.35-0ubuntu3.8 29 | libc6:amd64 2.35-0ubuntu3.8 30 | libcap-ng0:amd64 0.7.9-2.2build3 31 | libcap2:amd64 1:2.44-1ubuntu0.22.04.1 32 | libcom-err2:amd64 1.46.5-2ubuntu1.2 33 | libcrypt1:amd64 1:4.4.27-1 34 | libdb5.3:amd64 5.3.28+dfsg1-0.8ubuntu3 35 | libdebconfclient0:amd64 0.261ubuntu1 36 | libext2fs2:amd64 1.46.5-2ubuntu1.2 37 | libffi8:amd64 3.4.2-4 38 | libgcc-s1:amd64 12.3.0-1ubuntu1~22.04 39 | libgcrypt20:amd64 1.9.4-3ubuntu3 40 | libgmp10:amd64 2:6.2.1+dfsg-3ubuntu1 41 | libgnutls30:amd64 3.7.3-4ubuntu1.5 42 | libgpg-error0:amd64 1.43-3 43 | libgssapi-krb5-2:amd64 1.19.2-2ubuntu0.4 44 | libhogweed6:amd64 3.7.3-1build2 45 | libidn2-0:amd64 2.3.2-2build1 46 | libk5crypto3:amd64 1.19.2-2ubuntu0.4 47 | libkeyutils1:amd64 1.6.1-2ubuntu3 48 | libkrb5-3:amd64 1.19.2-2ubuntu0.4 49 | libkrb5support0:amd64 1.19.2-2ubuntu0.4 50 | liblz4-1:amd64 1.9.3-2build2 51 | liblzma5:amd64 5.2.5-2ubuntu1 52 | libmount1:amd64 2.37.2-4ubuntu3.4 53 | libncurses6:amd64 6.3-2ubuntu0.1 54 | libncursesw6:amd64 6.3-2ubuntu0.1 55 | libnettle8:amd64 3.7.3-1build2 56 | libnsl2:amd64 1.3.0-2build2 57 | libp11-kit0:amd64 0.24.0-6build1 58 | libpam-modules:amd64 1.4.0-11ubuntu2.4 59 | libpam-modules-bin 1.4.0-11ubuntu2.4 60 | libpam-runtime 1.4.0-11ubuntu2.4 61 | libpam0g:amd64 1.4.0-11ubuntu2.4 62 | libpcre2-8-0:amd64 10.39-3ubuntu0.1 63 | libpcre3:amd64 2:8.39-13ubuntu0.22.04.1 64 | libprocps8:amd64 2:3.3.17-6ubuntu2.1 65 | libseccomp2:amd64 2.5.3-2ubuntu2 66 | libselinux1:amd64 3.3-1build2 67 | libsemanage-common 3.3-1build2 68 | libsemanage2:amd64 3.3-1build2 69 | libsepol2:amd64 3.3-1build1 70 | libsmartcols1:amd64 2.37.2-4ubuntu3.4 71 | libss2:amd64 1.46.5-2ubuntu1.2 72 | libssl3:amd64 3.0.2-0ubuntu1.18 73 | libstdc++6:amd64 12.3.0-1ubuntu1~22.04 74 | libsystemd0:amd64 249.11-0ubuntu3.12 75 | libtasn1-6:amd64 4.18.0-4build1 76 | libtinfo6:amd64 6.3-2ubuntu0.1 77 | libtirpc-common 1.3.2-2ubuntu0.1 78 | libtirpc3:amd64 1.3.2-2ubuntu0.1 79 | libudev1:amd64 249.11-0ubuntu3.12 80 | libunistring2:amd64 1.0-1 81 | libuuid1:amd64 2.37.2-4ubuntu3.4 82 | libxxhash0:amd64 0.8.1-1 83 | libzstd1:amd64 1.4.8+dfsg-3build1 84 | login 1:4.8.1-2ubuntu2.2 85 | logsave 1.46.5-2ubuntu1.2 86 | lsb-base 11.1.0ubuntu4 87 | mawk 1.3.4.20200120-3 88 | mount 2.37.2-4ubuntu3.4 89 | ncurses-base 6.3-2ubuntu0.1 90 | ncurses-bin 6.3-2ubuntu0.1 91 | passwd 1:4.8.1-2ubuntu2.2 92 | perl-base 5.34.0-3ubuntu1.3 93 | procps 2:3.3.17-6ubuntu2.1 94 | sed 4.8-1ubuntu2 95 | sensible-utils 0.0.17 96 | sysvinit-utils 3.01-1ubuntu1 97 | tar 1.34+dfsg-1ubuntu0.1.22.04.2 98 | ubuntu-keyring 2021.03.26 99 | usrmerge 25ubuntu2 100 | util-linux 2.37.2-4ubuntu3.4 101 | zlib1g:amd64 1:1.2.11.dfsg-2ubuntu9.2 102 | -------------------------------------------------------------------------------- /testing/fixtures/snap/find-vim.txt: -------------------------------------------------------------------------------- 1 | Name Version Publisher Notes Summary 2 | vim-language-server 2.3.1 alexmurray* - VimScript Language Server 3 | vimix-themes 2020-02-24-15-g426d7e0 gantonayde - Vimix GTK and Icon Themes for GTK Snaps 4 | vim-editor 8.2.788 zilongzhaobur - vim, the text editor 5 | vimix 0.8.4 bruno-herbelin - Video live mixer 6 | vim-deb 0.1 bugwolf - A deb package for vim. 7 | clion 2025.1.1 jetbrains** classic A cross-platform IDE for C and C++ 8 | helix 25.01.1 lauren-brock classic Helix is a modal text editor inspired by Vim and Kakoune 9 | iamb v0.0.10 popey* - A Matrix client for Vim addicts 10 | ncspot 1.2.2 popey* - Cross-platform ncurses Spotify client written in Rust 11 | plumber 3.1 keshavnrj* - Media trimmer/downloader for Linux Desktop 12 | neovim-kalikiana 0.1.4 kalikiana - Vim-fork focused on extensibility and agility. 13 | chromeos-themes 2020-01-18-25-g765be0e gantonayde - ChromeOS GTK Themes for GTK Snaps 14 | nvim v0.11.1 neovim-snap classic Vim-fork focused on extensibility and usability 15 | kakoune v2023.08.05 lukewh classic Modal editor 16 | neovide 0.8.0+git j4qfrost - The snappiest vim editor you are likely to find. 17 | yazi shipped sxyazi classic 💥 Blazing fast terminal file manager written in Rust, based on async I/O. 18 | sudoku-rs 1.1 mitchel0022 - Sudoku right in the terminal 19 | 4ktube 2025.5.23 rishabh3354 - YouTube Video Downloader 🚀 20 | libretextus 0.2 npscript42 - Simple Bible Utility 21 | tpad 2.1 caozhen - Terminal text editor with GUI-like user interface 22 | universal-ctags 0.2024-05-27+09:10:28+653ca9204 tartley - Universal-Ctags packaged as an installable snap for Linux 23 | snippetpixie 1.5.3 bytepixie classic Your little expandable text snippet helper 24 | nvim-gtk 0.1.1 daa84 - GUI client for NeoVIM 25 | hexdino 0.1.3 luz666 - A hex editor with vim like keybindings written in Rust. 26 | slitherling e2a2e8c tejohnso - A simple snake game 27 | neomutt 20241212-14-g7b49f7c3f nicolasbock - NeoMutt is a command line mail reader (or MUA) 28 | notion-calendar-snap 2.0.0 ulvimammaadov - Unofficial Notion Calendar App for Linux distributions 29 | xcape-lbo 7fca364 lbo - Modify keys to act as other keys 30 | leo-editor 6.0-final technatica - Leo is an IDE, outliner and PIM. 31 | walk 1.6.2 antonmedv - A terminal navigator 32 | llama 1.4.0 antonmedv - A terminal file manager 33 | -------------------------------------------------------------------------------- /testing/os-matrix.yaml: -------------------------------------------------------------------------------- 1 | # OS/Package Manager Testing Matrix Configuration 2 | # This file defines which package managers should be tested on which operating systems 3 | 4 | matrix: 5 | # Debian-based distributions 6 | debian-family: 7 | distributions: 8 | - ubuntu:20.04 9 | - ubuntu:22.04 10 | - ubuntu:24.04 11 | - debian:11 12 | - debian:12 13 | package_managers: 14 | apt: 15 | available: true 16 | operations: [search, list, install, remove, upgrade, show] 17 | test_priority: high 18 | flatpak: 19 | available: true 20 | operations: [search, list, install, remove, show] 21 | test_priority: medium 22 | setup_required: true 23 | snap: 24 | available: false # Requires systemd in containers 25 | operations: [] 26 | test_priority: low 27 | notes: "Use native CI runners for snap testing" 28 | 29 | # RHEL-based distributions 30 | rhel-family: 31 | distributions: 32 | - rockylinux:8 33 | - rockylinux:9 34 | - almalinux:8 35 | - almalinux:9 36 | - fedora:38 37 | - fedora:39 38 | - centos:stream8 39 | - centos:stream9 40 | package_managers: 41 | yum: 42 | available: true 43 | operations: [search, list, show, clean, refresh] 44 | test_priority: high 45 | distributions: ["rockylinux:8", "almalinux:8", "centos:stream8"] 46 | dnf: 47 | available: true 48 | operations: [search, list, install, remove, upgrade, show] 49 | test_priority: high 50 | distributions: ["rockylinux:9", "almalinux:9", "fedora:38", "fedora:39", "centos:stream9"] 51 | flatpak: 52 | available: true 53 | operations: [search, list, show] 54 | test_priority: low 55 | setup_required: true 56 | 57 | # Alpine-based 58 | alpine-family: 59 | distributions: 60 | - alpine:3.17 61 | - alpine:3.18 62 | - alpine:3.19 63 | package_managers: 64 | apk: 65 | available: true 66 | operations: [search, list, install, remove, upgrade, show] 67 | test_priority: medium 68 | 69 | # Arch-based (future) 70 | arch-family: 71 | distributions: 72 | - archlinux:latest 73 | package_managers: 74 | pacman: 75 | available: true 76 | operations: [search, list, install, remove, upgrade, show] 77 | test_priority: low 78 | 79 | # Test execution strategy 80 | test_strategy: 81 | # Unit tests (parser functions) - run on all OS 82 | unit: 83 | scope: all_distributions 84 | method: docker 85 | fixture_dependent: true 86 | 87 | # Integration tests (real commands, limited operations) 88 | integration: 89 | scope: primary_distributions # subset for CI efficiency 90 | method: docker 91 | operations: [search, list, show, clean, refresh] 92 | 93 | # Full system tests (actual installs/removes) 94 | system: 95 | scope: native_runners_only 96 | method: github_actions_matrix 97 | operations: [install, remove, upgrade] 98 | require_privileges: true 99 | 100 | # Primary distributions for CI (to limit resource usage) 101 | primary_distributions: 102 | - ubuntu:22.04 # APT testing 103 | - rockylinux:9 # DNF testing 104 | - almalinux:8 # YUM testing 105 | - alpine:3.18 # APK testing 106 | - fedora:39 # Latest DNF 107 | 108 | # Test fixtures to generate per OS 109 | fixtures: 110 | apt: 111 | commands: 112 | - "apt update && apt search vim" 113 | - "apt show vim" 114 | - "apt list --installed | head -20" 115 | - "apt list --upgradable" 116 | 117 | yum: 118 | commands: 119 | - "yum search vim" 120 | - "yum info vim" 121 | - "yum list --installed | head -20" 122 | 123 | dnf: 124 | commands: 125 | - "dnf search vim" 126 | - "dnf info vim" 127 | - "dnf list --installed | head -20" 128 | - "dnf list --upgrades" 129 | 130 | apk: 131 | commands: 132 | - "apk search vim" 133 | - "apk info vim" 134 | - "apk list --installed" 135 | -------------------------------------------------------------------------------- /testing/docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker-Based Testing Strategy 2 | 3 | ## Overview 4 | 5 | This directory contains Docker configurations for testing go-syspkg across multiple Linux distributions. The multi-OS Docker testing system is **fully implemented and actively used** for comprehensive cross-platform validation. 6 | 7 | ## 📖 Related Documentation 8 | 9 | - **[../../README.md](../../README.md)** - Project overview 10 | - **[../../CONTRIBUTING.md](../../CONTRIBUTING.md)** - Complete development workflow and testing guide 11 | - **[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)** - Technical design and interfaces 12 | - **[../../docs/EXIT_CODES.md](../../docs/EXIT_CODES.md)** - Package manager exit code behavior 13 | 14 | ## Test Categories 15 | 16 | ### 1. Unit Tests (Run Everywhere) 17 | - Parser functions with captured outputs 18 | - OS detection logic 19 | - Command construction 20 | - No actual package manager execution 21 | 22 | ### 2. Integration Tests (Container-Specific) 23 | - Real package manager availability checks 24 | - Command output capture for test fixtures 25 | - Limited package operations (list, search) 26 | 27 | ### 3. Full System Tests (Native CI Only) 28 | - Actual package installation/removal 29 | - Privileged operations 30 | - Snap/systemd dependent features 31 | 32 | ## Current Docker Test Implementation 33 | 34 | **Note:** The Docker testing system is fully operational with comprehensive multi-OS support. 35 | 36 | **Supported Operating Systems:** 37 | - **Ubuntu 22.04**: APT, Snap, Flatpak testing 38 | - **Rocky Linux 8**: YUM testing with real RPM packages 39 | - **AlmaLinux 8**: YUM testing and validation 40 | - **Fedora 39**: DNF testing (implementation in progress) 41 | - **Alpine Linux**: APK testing (implementation in progress) 42 | 43 | **Architecture Support:** 44 | - **AMD64 (x86_64), ARM64 (aarch64/Apple Silicon)** 45 | 46 | ## Usage 47 | 48 | ### Run All Container Tests 49 | ```bash 50 | make test-docker-all # Test all OS in parallel 51 | ``` 52 | 53 | ### Run Specific OS Tests 54 | ```bash 55 | make test-docker-ubuntu # Test APT/Snap/Flatpak on Ubuntu 56 | make test-docker-rocky # Test YUM on Rocky Linux 8 57 | make test-docker-alma # Test YUM on AlmaLinux 8 58 | make test-docker-fedora # Test DNF on Fedora 39 59 | make test-docker-alpine # Test APK on Alpine Linux 60 | ``` 61 | 62 | ### Cleanup Docker Resources 63 | ```bash 64 | make test-docker-clean # Remove test containers and images 65 | ``` 66 | 67 | ### Generate Test Fixtures 68 | ```bash 69 | # Generate fresh fixtures from real package managers 70 | make test-fixtures # Capture outputs from all supported OS 71 | 72 | # Manual fixture generation for specific OS 73 | docker exec -it syspkg-rocky-test bash 74 | yum search vim > /workspace/testing/fixtures/yum/search-vim-rocky8.txt 75 | ``` 76 | 77 | ## Current CI Integration 78 | 79 | The Docker testing system is fully integrated into CI/CD: 80 | 81 | **GitHub Actions Workflows:** 82 | - **test-and-coverage.yml**: Standard Ubuntu testing with APT/Snap/Flatpak 83 | - **multi-os-test.yml**: Docker matrix testing across all supported OS 84 | - **build.yml**: Multi-version Go build verification 85 | 86 | **Multi-OS Testing Matrix:** 87 | ```yaml 88 | # .github/workflows/multi-os-test.yml (active) 89 | strategy: 90 | matrix: 91 | include: 92 | - os: ubuntu-22.04 93 | pm: apt 94 | dockerfile: ubuntu.Dockerfile 95 | - os: rockylinux-8 96 | pm: yum 97 | dockerfile: rockylinux.Dockerfile 98 | - os: almalinux-8 99 | pm: yum 100 | dockerfile: almalinux.Dockerfile 101 | - os: fedora-39 102 | pm: dnf 103 | dockerfile: fedora.Dockerfile 104 | - os: alpine-3.18 105 | pm: apk 106 | dockerfile: alpine.Dockerfile 107 | ``` 108 | 109 | ## Best Practices 110 | 111 | 1. **Keep Images Minimal**: Install only what's required for testing 112 | 2. **Cache Aggressively**: Use Docker layer caching 113 | 3. **Parallelize Tests**: Run different OS tests concurrently 114 | 4. **Mock External Calls**: Don't actually install packages in tests 115 | 5. **Capture Real Outputs**: Use containers to generate test fixtures 116 | -------------------------------------------------------------------------------- /osinfo/osinfo.go: -------------------------------------------------------------------------------- 1 | // Package osinfo provides a simple way to gather information about the 2 | // operating system, including the name, distribution, version, and architecture. 3 | // Description: Get OS information. Including: OS Type, Distribution, Version, CPU Architecture. 4 | // * Name: OS name (ex, linux, darwin, windows) 5 | // * Distribution: OS distribution (ex, Ubuntu) 6 | // * Version: OS version (ex, 20.04) 7 | // * Arch: OS architecture (ex, amd64) 8 | // 9 | // Author: BlueT - Matthew Lien - 練喆明 10 | 11 | package osinfo 12 | 13 | import ( 14 | "bufio" 15 | "errors" 16 | "fmt" 17 | "os" 18 | "os/exec" 19 | "runtime" 20 | "strings" 21 | ) 22 | 23 | // OSInfo represents the operating system information, including its name, 24 | // distribution, version, and architecture. 25 | type OSInfo struct { 26 | Name string 27 | Distribution string 28 | Version string 29 | Arch string 30 | } 31 | 32 | // GetOSInfo returns the OS information, as a pointer to an OSInfo struct. 33 | // The OSInfo struct contains the following fields: 34 | // - Name: OS name (ex, linux, darwin, windows) 35 | // - Distribution: OS distribution (ex, Ubuntu) 36 | // - Version: OS version (ex, 20.04) 37 | // - Arch: OS architecture (ex, amd64) 38 | func GetOSInfo() (*OSInfo, error) { 39 | osName := runtime.GOOS 40 | osArch := runtime.GOARCH 41 | var osDist, osVersion string 42 | var err error 43 | 44 | switch osName { 45 | case "linux": 46 | dist, ver, err := getLinuxDistribution() 47 | if err != nil { 48 | return nil, err 49 | } 50 | osDist = dist 51 | osVersion = ver 52 | case "darwin": 53 | osDist = "macOS" 54 | osVersion, err = getMacOSVersion() 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to get macOS version: %v", err) 57 | } 58 | case "windows": 59 | osDist = "Windows" 60 | osVersion, err = getWindowsVersion() 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to get Windows version: %v", err) 63 | } 64 | default: 65 | osDist = "N/A" 66 | osVersion = "N/A" 67 | } 68 | 69 | return &OSInfo{ 70 | Name: osName, 71 | Version: osVersion, 72 | Distribution: osDist, 73 | Arch: osArch, 74 | }, nil 75 | } 76 | 77 | // getLinuxDistribution returns the Linux distribution name and version. 78 | // Parse the content of /etc/os-release to get the distribution name and version. 79 | func getLinuxDistribution() (string, string, error) { 80 | file, err := os.Open("/etc/os-release") 81 | if err != nil { 82 | return "", "", err 83 | } 84 | defer file.Close() 85 | 86 | var dist, distVersion string 87 | scanner := bufio.NewScanner(file) 88 | for scanner.Scan() { 89 | line := scanner.Text() 90 | if strings.HasPrefix(line, "ID=") { 91 | dist = strings.TrimPrefix(line, "ID=") 92 | dist = strings.Trim(dist, "\"") 93 | } else if strings.HasPrefix(line, "VERSION_ID=") { 94 | distVersion = strings.TrimPrefix(line, "VERSION_ID=") 95 | distVersion = strings.Trim(distVersion, "\"") 96 | } 97 | } 98 | 99 | if err := scanner.Err(); err != nil { 100 | return "", "", err 101 | } 102 | 103 | return dist, distVersion, nil 104 | } 105 | 106 | // getMacOSVersion returns the macOS version as a string. 107 | func getMacOSVersion() (string, error) { 108 | out, err := exec.Command("sw_vers", "-productVersion").Output() 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | macOSVersion := strings.TrimSpace(string(out)) 114 | return macOSVersion, nil 115 | } 116 | 117 | // getWindowsVersion returns the Windows version as a string. 118 | func getWindowsVersion() (string, error) { 119 | out, err := exec.Command("cmd", "/C", "ver").Output() 120 | if err != nil { 121 | return "", err 122 | } 123 | 124 | output := strings.TrimSpace(string(out)) 125 | // The output will be in the format: "Microsoft Windows [Version X.Y.Z]". 126 | // We need to extract X.Y.Z from the output. 127 | start := strings.Index(output, "[") 128 | end := strings.Index(output, "]") 129 | 130 | if start == -1 || end == -1 { 131 | return "", errors.New("failed to parse Windows version") 132 | } 133 | 134 | // Get the version string and remove the "Version" prefix. 135 | version := strings.TrimPrefix(strings.TrimSpace(output[start+1:end]), "Version ") 136 | 137 | return version, nil 138 | } 139 | -------------------------------------------------------------------------------- /manager/yum/yum_test.go: -------------------------------------------------------------------------------- 1 | // yum/yum_test.go 2 | package yum_test 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/bluet/syspkg/manager" 8 | "github.com/bluet/syspkg/manager/yum" 9 | ) 10 | 11 | func TestYumPackageManagerNotAvailable(t *testing.T) { 12 | yumManager := yum.PackageManager{} 13 | 14 | // Skip this test if YUM is actually available (like in Rocky Linux CI environment) 15 | if yumManager.IsAvailable() { 16 | t.Skip("Skipping 'not available' test because YUM is available in this environment") 17 | } 18 | 19 | // This test validates behavior when YUM is not available on the system 20 | opts := manager.Options{} 21 | packages := []string{"nginx"} 22 | 23 | _, erri := yumManager.Install(packages, nil) 24 | if erri == nil { 25 | t.Fatal("YumPackageManager should not support installation when not available") 26 | } 27 | _, errd := yumManager.Delete(packages, nil) 28 | if errd == nil { 29 | t.Fatal("YumPackageManager should not support removal when not available") 30 | } 31 | _, errlu := yumManager.ListUpgradable(&opts) 32 | if errlu == nil { 33 | t.Fatal("YumPackageManager should not support list-upgradable when not available") 34 | } 35 | _, erru := yumManager.Upgrade(packages, nil) 36 | if erru == nil { 37 | t.Fatal("YumPackageManager should not support upgrade when not available") 38 | } 39 | _, errua := yumManager.UpgradeAll(&opts) 40 | if errua == nil { 41 | t.Fatal("YumPackageManager should not support upgrade-all when not available") 42 | } 43 | _, errar := yumManager.AutoRemove(&opts) 44 | if errar == nil { 45 | t.Fatal("YumPackageManager should not support autoremove when not available") 46 | } 47 | } 48 | 49 | /* 50 | // these e2e tests work only under a RHEL derived Linux distro that yum installed 51 | 52 | func TestYumPackageManagerIsAvailable(t *testing.T) { 53 | yumManager := yum.PackageManager{} 54 | if !yumManager.IsAvailable() { 55 | t.Fatal("YumPackageManager is not available") 56 | } 57 | } 58 | 59 | func TestYumPackageManagerListPackages(t *testing.T) { 60 | yumManager := yum.PackageManager{} 61 | opts:=manager.Options{} 62 | result,err:=yumManager.ListInstalled(&opts) 63 | if err!=nil{ 64 | t.Errorf("Should have been able to list correctly, %s",err) 65 | } 66 | if len(result)==0{ 67 | t.Fatal("Zero packages detected, there should have been at least one") 68 | } 69 | } 70 | 71 | func TestYumPackageManagerGetPackageInfo(t *testing.T) { 72 | yumManager := yum.PackageManager{} 73 | opts:=manager.Options{} 74 | packages:="rpm" 75 | 76 | result,err:=yumManager.GetPackageInfo(packages, &opts) 77 | if err!=nil{ 78 | t.Errorf("Should have been able to get info correctly, %s",err) 79 | } 80 | if result.Name != packages { 81 | 82 | t.Errorf("rpm should be present, found %+v", result) 83 | } 84 | } 85 | 86 | func TestYumPackageManagerFind(t *testing.T) { 87 | yumManager := yum.PackageManager{} 88 | opts:=manager.Options{} 89 | packages:=[]string{"nginx"} 90 | 91 | result,err:=yumManager.Find(packages, &opts) 92 | if err!=nil{ 93 | t.Errorf("Should have been able to search correctly, %s",err) 94 | } 95 | if len(result)==0 { 96 | t.Errorf("nginx should be present, found %+v", result) 97 | } 98 | if result[0].Name != packages[0] { 99 | t.Errorf("nginx should be available, found %+v", result) 100 | } 101 | } 102 | */ 103 | 104 | // TestParseFindOutput_DeprecatedInlineData is kept for backwards compatibility 105 | // New tests should use behavior_test.go with fixtures instead 106 | func TestParseFindOutput_DeprecatedInlineData(t *testing.T) { 107 | t.Skip("Deprecated: inline test data replaced with fixture-based tests in behavior_test.go") 108 | } 109 | 110 | // TestParseListInstalledOutput_DeprecatedInlineData is kept for backwards compatibility 111 | // New tests should use behavior_test.go with fixtures instead 112 | func TestParseListInstalledOutput_DeprecatedInlineData(t *testing.T) { 113 | t.Skip("Deprecated: inline test data replaced with fixture-based tests in behavior_test.go") 114 | } 115 | 116 | // TestParsePackageInfoOutput_DeprecatedInlineData is kept for backwards compatibility 117 | // New tests should use behavior_test.go with fixtures instead 118 | func TestParsePackageInfoOutput_DeprecatedInlineData(t *testing.T) { 119 | t.Skip("Deprecated: inline test data replaced with fixture-based tests in behavior_test.go") 120 | } 121 | -------------------------------------------------------------------------------- /manager/packageinfo.go: -------------------------------------------------------------------------------- 1 | // Package manager provides utilities for managing the application. 2 | package manager 3 | 4 | // PackageStatus represents the current status of a package in the system. 5 | type PackageStatus string 6 | 7 | // PackageStatus constants define possible statuses for packages across all package managers. 8 | // These statuses are normalized for cross-package manager compatibility. 9 | const ( 10 | // PackageStatusInstalled represents a package that is currently installed and functional. 11 | // Used by: All package managers 12 | PackageStatusInstalled PackageStatus = "installed" 13 | 14 | // PackageStatusUpgradable represents an installed package that has a newer version available. 15 | // Used by: All package managers 16 | PackageStatusUpgradable PackageStatus = "upgradable" 17 | 18 | // PackageStatusAvailable represents a package that exists in repositories but is not installed. 19 | // This includes packages that were previously installed but removed (including config-files state). 20 | // For cross-package manager compatibility, APT's config-files state is normalized to this status. 21 | // Used by: All package managers 22 | PackageStatusAvailable PackageStatus = "available" 23 | 24 | // PackageStatusUnknown represents a package with an unknown or error state. 25 | // This is rare and typically indicates system errors or corrupted package databases. 26 | // Used by: All package managers (rare cases) 27 | PackageStatusUnknown PackageStatus = "unknown" 28 | 29 | // PackageStatusConfigFiles represents a package with only configuration files remaining. 30 | // Note: This is deprecated and normalized to PackageStatusAvailable for cross-PM compatibility. 31 | // Only kept for internal use by APT implementation. 32 | PackageStatusConfigFiles PackageStatus = "config-files" 33 | ) 34 | 35 | // PackageInfo contains information about a specific package. 36 | // Field usage varies by operation and package status: 37 | // 38 | // Field Usage by Operation: 39 | // 40 | // Install: Version=installed_version, NewVersion=installed_version, Status=installed 41 | // Delete: Version=removed_version, NewVersion="", Status=available 42 | // Find: Version=installed_version (or ""), NewVersion=repo_version, Status=installed/available/upgradable 43 | // ListInstalled: Version=installed_version, NewVersion="", Status=installed 44 | // ListUpgradable: Version=current_version, NewVersion=available_version, Status=upgradable 45 | // GetPackageInfo: Version=available_version, NewVersion="", Status varies 46 | // 47 | // Field Usage by Status: 48 | // 49 | // installed: Version=installed_version, NewVersion=installed_version (Install) or "" (ListInstalled) 50 | // available: Version="" (not installed) or removed_version (Delete), NewVersion=repo_version 51 | // upgradable: Version=current_version, NewVersion=newer_version 52 | // unknown: Version="", NewVersion may contain repo_version 53 | type PackageInfo struct { 54 | // Name is the package name. 55 | Name string 56 | 57 | // Version is the currently installed version of the package. 58 | // Empty if package is not installed (Status=available). 59 | // Contains removed version for Delete operations. 60 | Version string 61 | 62 | // NewVersion is the latest available version from repositories. 63 | // Used for available versions in Find operations and upgrade targets. 64 | // Empty for ListInstalled operations. 65 | // Same as Version for Install operations. 66 | NewVersion string 67 | 68 | // Status indicates the current PackageStatus of the package. 69 | // See PackageStatus constants for detailed descriptions. 70 | Status PackageStatus 71 | 72 | // Category is the category/section the package belongs to. 73 | // Examples: "utilities", "development", "web", "jammy", "main" 74 | // May represent repository sections or package categories depending on package manager. 75 | Category string 76 | 77 | // Arch is the architecture the package is built for. 78 | // Examples: "amd64", "arm64", "i386", "all" 79 | // Empty if architecture is not specified or not applicable. 80 | Arch string 81 | 82 | // PackageManager is the name of the package manager used to manage this package. 83 | // Examples: "apt", "yum", "dnf", "snap", "flatpak" 84 | PackageManager string 85 | 86 | // AdditionalData is a map of key-value pairs for additional package-specific metadata. 87 | // Used for package manager specific information that doesn't fit standard fields. 88 | AdditionalData map[string]string 89 | } 90 | -------------------------------------------------------------------------------- /testing/fixtures/snap/list.txt: -------------------------------------------------------------------------------- 1 | Name Version Rev Tracking Publisher Notes 2 | bare 1.0 5 latest/stable canonical** base 3 | blablaland-desktop 1.0.1 3 latest/edge adedev - 4 | canonical-livepatch 10.10.3 316 latest/stable canonical** - 5 | caprine 2.59.3 65 latest/stable sindresorhus - 6 | chromium 136.0.7103.113 3137 latest/stable canonical** - 7 | core 16-2.61.4-20241002 17210 latest/stable canonical** core 8 | core18 20250123 2855 latest/stable canonical** base 9 | core20 20250429 2582 latest/stable canonical** base 10 | core22 20250408 1963 latest/stable canonical** base 11 | core24 20250504 988 latest/stable canonical** base 12 | cups 2.4.12-2 1100 latest/stable openprinting** - 13 | deja-dup 46.1 624 latest/stable mterry classic 14 | duplicity 3.0.4 524 latest/stable kenneth-loafman classic 15 | figma-linux 0.11.4 197 latest/stable youdonthavepermissiony - 16 | firefox 139.0-2 6227 latest/stable mozilla** - 17 | flutter 0+git.1fa6fd6 149 latest/stable flutter-team** classic 18 | gitkraken 11.1.1 260 latest/stable gitkraken** classic 19 | gnome-3-26-1604 3.26.0.20221130 111 latest/stable/… canonical** - 20 | gnome-3-28-1804 3.28.0-19-g98f9e67.98f9e67 198 latest/stable canonical** - 21 | gnome-3-34-1804 0+git.3556cb3 93 latest/stable canonical** - 22 | gnome-3-38-2004 0+git.efb213a 143 latest/stable canonical** - 23 | gnome-42-2204 0+git.38ea591 202 latest/stable canonical** - 24 | gnome-46-2404 0+git.d9f8bf6-sdk0+git.c8a281c 90 latest/stable canonical** - 25 | gnome-firmware 45.0-16-g1e19cec 8 latest/stable superm1 - 26 | gnome-system-monitor 48.1 193 latest/stable/… canonical** - 27 | google-cloud-cli 524.0.0 341 latest/stable google-cloud-sdk** classic 28 | gtk-common-themes 0.1-81-g442e511 1535 latest/stable/… canonical** - 29 | hunspell-dictionaries-1-7-2004 1.7-20.04+pkg-6fd6 2 latest/stable brlin - 30 | journey 2.14.6 23 latest/stable 2appstudio** - 31 | mesa-2404 24.2.8 495 latest/stable canonical** - 32 | multipass 1.15.1 14535 latest/stable canonical** - 33 | postman 11.47.4 331 latest/beta postman-inc** - 34 | pwdsafety v0.4.0 2 latest/stable edoardottt - 35 | snap-store 41.3-72-g80e7130 1216 latest/stable/… canonical** - 36 | snapd 2.68.4 24505 latest/stable canonical** snapd 37 | snapd-desktop-integration 0.9 253 latest/stable/… canonical** - 38 | telegram-desktop 5.14.3 6639 latest/stable telegram-desktop** - 39 | utilso 4.4.0 34 latest/stable sleptsov - 40 | -------------------------------------------------------------------------------- /syspkg.go: -------------------------------------------------------------------------------- 1 | // Package syspkg provides a unified interface for interacting with multiple package management systems. 2 | // It allows you to query, install, and remove packages, and supports package managers like Apt, Snap, and Flatpak. 3 | // 4 | // To get started, create a new SysPkg instance by calling the New() function with the desired IncludeOptions. 5 | // After obtaining a SysPkg instance, you can use the FindPackageManagers() function to find available package managers 6 | // on the system, and GetPackageManager() to get a specific package manager. 7 | // 8 | // Example: 9 | // 10 | // includeOptions := syspkg.IncludeOptions{ 11 | // AllAvailable: true, 12 | // } 13 | // sysPkg, err := syspkg.New(includeOptions) 14 | // if err != nil { 15 | // log.Fatal(err) 16 | // } 17 | // aptManager, err := sysPkg.GetPackageManager("apt") 18 | package syspkg 19 | 20 | import ( 21 | "errors" 22 | "log" 23 | "sort" 24 | 25 | "github.com/bluet/syspkg/manager" 26 | "github.com/bluet/syspkg/manager/apt" 27 | "github.com/bluet/syspkg/manager/flatpak" 28 | "github.com/bluet/syspkg/manager/snap" 29 | "github.com/bluet/syspkg/manager/yum" 30 | // "github.com/bluet/syspkg/zypper" 31 | // "github.com/bluet/syspkg/dnf" 32 | // "github.com/bluet/syspkg/apk" 33 | ) 34 | 35 | // PackageInfo represents a package's information. 36 | type PackageInfo = manager.PackageInfo 37 | 38 | // IncludeOptions specifies which package managers to include when creating a SysPkg instance. 39 | type IncludeOptions struct { 40 | AllAvailable bool 41 | Apk bool 42 | Apt bool 43 | Dnf bool 44 | Flatpak bool 45 | Snap bool 46 | Yum bool 47 | Zypper bool 48 | } 49 | 50 | type sysPkgImpl struct { 51 | pms map[string]PackageManager 52 | } 53 | 54 | // make sure sysPkgImpl implements SysPkg 55 | var _ SysPkg = (*sysPkgImpl)(nil) 56 | 57 | // New creates a new SysPkg instance with the specified IncludeOptions. 58 | func New(include IncludeOptions) (SysPkg, error) { 59 | impl := &sysPkgImpl{} 60 | pms, err := impl.FindPackageManagers(include) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return &sysPkgImpl{ 66 | pms: pms, 67 | }, nil 68 | } 69 | 70 | // FindPackageManagers returns a map of available package managers based on the specified IncludeOptions. 71 | func (s *sysPkgImpl) FindPackageManagers(include IncludeOptions) (map[string]PackageManager, error) { 72 | var pms = make(map[string]PackageManager) 73 | managerList := []struct { 74 | managerName string 75 | manager PackageManager 76 | include bool 77 | }{ 78 | {"apt", &apt.PackageManager{}, include.Apt}, 79 | {"flatpak", &flatpak.PackageManager{}, include.Flatpak}, 80 | {"snap", &snap.PackageManager{}, include.Snap}, 81 | {"yum", yum.NewPackageManager(), include.Yum}, 82 | // {"apk", &apk.PackageManager{}, include.Apk}, 83 | // {"dnf", &dnf.PackageManager{}, include.Dnf}, 84 | // {"zypper", &zypper.PackageManager{}, include.Zypper}, 85 | } 86 | 87 | for _, m := range managerList { 88 | if include.AllAvailable || m.include { 89 | if m.manager.IsAvailable() { 90 | pms[m.managerName] = m.manager 91 | log.Printf("%s manager is available", m.managerName) 92 | } 93 | } 94 | } 95 | 96 | if len(pms) == 0 { 97 | return nil, errors.New("no supported package manager found") 98 | } 99 | 100 | return pms, nil 101 | } 102 | 103 | // GetPackageManager returns a PackageManager instance by its name (e.g., "apt", "snap", "flatpak", etc.). 104 | // if name is empty, return the first available 105 | func (s *sysPkgImpl) GetPackageManager(name string) (PackageManager, error) { 106 | // if there are no package managers, return before accessing non existing properties 107 | if len(s.pms) == 0 { 108 | return nil, errors.New("no supported package manager detected") 109 | } 110 | 111 | if name == "" { 112 | // get first pm available, lexicographically sorted 113 | keys := make([]string, 0, len(s.pms)) 114 | for k := range s.pms { 115 | keys = append(keys, k) 116 | } 117 | sort.Strings(keys) 118 | return s.pms[keys[0]], nil 119 | } 120 | 121 | pm, found := s.pms[name] 122 | if !found { 123 | return nil, errors.New("no such package manager") 124 | } 125 | return pm, nil 126 | } 127 | 128 | // RefreshPackageManagers refreshes the internal list of available package managers, and returns the new list. 129 | func (s *sysPkgImpl) RefreshPackageManagers(include IncludeOptions) (map[string]PackageManager, error) { 130 | pms, err := s.FindPackageManagers(include) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | s.pms = pms 136 | return pms, nil 137 | } 138 | -------------------------------------------------------------------------------- /manager/yum/yum_mock_test.go: -------------------------------------------------------------------------------- 1 | package yum_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/bluet/syspkg/manager" 8 | "github.com/bluet/syspkg/manager/yum" 9 | ) 10 | 11 | // TestYUM_WithMockedCommands demonstrates testing YUM operations with mocked commands 12 | func TestYUM_WithMockedCommands(t *testing.T) { 13 | t.Run("Find with mocked commands", func(t *testing.T) { 14 | // Create mock command runner 15 | mock := manager.NewMockCommandRunner() 16 | 17 | // Mock yum search output 18 | searchOutput := `========================= Name & Summary Matched: vim ========================== 19 | vim-enhanced.x86_64 : A version of the VIM editor which includes recent enhancements 20 | vim-minimal.x86_64 : A minimal version of the VIM editor 21 | ` 22 | mock.AddCommand("yum", []string{"search", "vim"}, []byte(searchOutput), nil) 23 | 24 | // Mock rpm --version (for status check) 25 | mock.AddCommand("rpm", []string{"--version"}, []byte("RPM version 4.14.3\n"), nil) 26 | 27 | // Mock rpm -q for status detection 28 | mock.AddCommand("rpm", []string{"-q", "vim-enhanced"}, 29 | []byte("vim-enhanced-8.0.1763-19.el8_6.4.x86_64\n"), nil) 30 | mock.AddCommand("rpm", []string{"-q", "vim-minimal"}, nil, 31 | errors.New("package vim-minimal is not installed")) 32 | 33 | // Create YUM package manager with mocked runner 34 | pm := yum.NewPackageManagerWithCustomRunner(mock) 35 | 36 | // Execute Find operation 37 | packages, err := pm.Find([]string{"vim"}, &manager.Options{}) 38 | if err != nil { 39 | t.Fatalf("Find failed: %v", err) 40 | } 41 | 42 | // Verify results 43 | if len(packages) != 2 { 44 | t.Fatalf("Expected 2 packages, got %d", len(packages)) 45 | } 46 | 47 | // Check status detection worked correctly 48 | for _, pkg := range packages { 49 | switch pkg.Name { 50 | case "vim-enhanced": 51 | if pkg.Status != manager.PackageStatusInstalled { 52 | t.Errorf("vim-enhanced should be installed, got %s", pkg.Status) 53 | } 54 | if pkg.Version == "" { 55 | t.Error("vim-enhanced should have version") 56 | } 57 | case "vim-minimal": 58 | if pkg.Status != manager.PackageStatusAvailable { 59 | t.Errorf("vim-minimal should be available, got %s", pkg.Status) 60 | } 61 | if pkg.Version != "" { 62 | t.Error("vim-minimal should not have version") 63 | } 64 | default: 65 | t.Errorf("Unexpected package: %s", pkg.Name) 66 | } 67 | } 68 | }) 69 | 70 | t.Run("Install with mocked commands", func(t *testing.T) { 71 | // Create mock command runner 72 | mock := manager.NewMockCommandRunner() 73 | 74 | // Mock yum install output 75 | installOutput := `Installing: 76 | vim-enhanced x86_64 2:8.0.1763-19.el8_6.4 appstream 1.4 M 77 | 78 | Transaction Summary 79 | ================================================================================ 80 | Install 1 Package 81 | 82 | Installed: 83 | vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64 84 | 85 | Complete! 86 | ` 87 | mock.AddCommand("yum", []string{"install", "-y", "vim-enhanced"}, 88 | []byte(installOutput), nil) 89 | 90 | // Create YUM package manager with mocked runner 91 | pm := yum.NewPackageManagerWithCustomRunner(mock) 92 | 93 | // Execute Install operation 94 | packages, err := pm.Install([]string{"vim-enhanced"}, &manager.Options{}) 95 | if err != nil { 96 | t.Fatalf("Install failed: %v", err) 97 | } 98 | 99 | // Verify results 100 | if len(packages) != 1 { 101 | t.Fatalf("Expected 1 package installed, got %d", len(packages)) 102 | } 103 | 104 | pkg := packages[0] 105 | if pkg.Name != "vim-enhanced" { 106 | t.Errorf("Expected vim-enhanced, got %s", pkg.Name) 107 | } 108 | if pkg.Status != manager.PackageStatusInstalled { 109 | t.Errorf("Expected installed status, got %s", pkg.Status) 110 | } 111 | if pkg.Version == "" { 112 | t.Error("Installed package should have version") 113 | } 114 | }) 115 | 116 | t.Run("Error handling with mocked commands", func(t *testing.T) { 117 | // Create mock command runner 118 | mock := manager.NewMockCommandRunner() 119 | 120 | // Mock command failure 121 | mock.AddCommand("yum", []string{"install", "-y", "nonexistent-package"}, nil, 122 | errors.New("No package nonexistent-package available")) 123 | 124 | // Create YUM package manager with mocked runner 125 | pm := yum.NewPackageManagerWithCustomRunner(mock) 126 | 127 | // Execute Install operation that should fail 128 | _, err := pm.Install([]string{"nonexistent-package"}, &manager.Options{}) 129 | if err == nil { 130 | t.Error("Expected error for nonexistent package") 131 | } 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /manager/yum/yum_test_enhanced.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package yum 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | 10 | "github.com/bluet/syspkg/manager" 11 | "github.com/bluet/syspkg/testing/testenv" 12 | ) 13 | 14 | // TestYumIntegrationEnvironmentAware demonstrates environment-aware testing 15 | func TestYumIntegrationEnvironmentAware(t *testing.T) { 16 | env, err := testenv.GetTestEnvironment() 17 | if err != nil { 18 | t.Fatalf("Failed to get test environment: %v", err) 19 | } 20 | 21 | // Skip if YUM not available in this environment 22 | if skip, reason := env.ShouldSkipTest("yum"); skip { 23 | t.Skip(reason) 24 | } 25 | 26 | yumManager := PackageManager{} 27 | 28 | // Test availability 29 | if !yumManager.IsAvailable() { 30 | t.Skip("YUM not available in this environment") 31 | } 32 | 33 | t.Run("ListInstalled", func(t *testing.T) { 34 | opts := &manager.Options{} 35 | packages, err := yumManager.ListInstalled(opts) 36 | 37 | if err != nil { 38 | t.Errorf("ListInstalled failed: %v", err) 39 | return 40 | } 41 | 42 | if len(packages) == 0 { 43 | t.Log("No packages found (expected in minimal containers)") 44 | } else { 45 | t.Logf("Found %d installed packages", len(packages)) 46 | 47 | // Log first few packages for debugging 48 | for i, pkg := range packages { 49 | if i >= 3 { 50 | break 51 | } 52 | t.Logf("Package: %s, Version: %s, Arch: %s", 53 | pkg.Name, pkg.Version, pkg.Arch) 54 | } 55 | } 56 | }) 57 | 58 | t.Run("SearchVim", func(t *testing.T) { 59 | if env.InContainer { 60 | t.Log("Running search in container environment") 61 | } 62 | 63 | opts := &manager.Options{} 64 | packages, err := yumManager.Find([]string{"vim"}, opts) 65 | 66 | if err != nil { 67 | t.Errorf("Find failed: %v", err) 68 | return 69 | } 70 | 71 | if len(packages) == 0 { 72 | t.Log("No vim packages found (may be expected in some environments)") 73 | } else { 74 | t.Logf("Found %d vim-related packages", len(packages)) 75 | 76 | // Verify at least one package has "vim" in the name 77 | found := false 78 | for _, pkg := range packages { 79 | if pkg.Name == "vim" { 80 | found = true 81 | t.Logf("Found vim package: %s, Arch: %s", pkg.Name, pkg.Arch) 82 | break 83 | } 84 | } 85 | 86 | if !found { 87 | t.Log("No exact 'vim' match found, but related packages exist") 88 | } 89 | } 90 | }) 91 | 92 | t.Run("GetPackageInfo", func(t *testing.T) { 93 | // Test with a package that should exist in most RHEL-based systems 94 | testPackage := "bash" 95 | 96 | opts := &manager.Options{} 97 | pkg, err := yumManager.GetPackageInfo(testPackage, opts) 98 | 99 | if err != nil { 100 | t.Logf("GetPackageInfo for %s failed: %v (may be expected in containers)", testPackage, err) 101 | return 102 | } 103 | 104 | if pkg.Name == "" { 105 | t.Error("Package info returned empty name") 106 | } else { 107 | t.Logf("Package info: Name=%s, Version=%s, Arch=%s", 108 | pkg.Name, pkg.Version, pkg.Arch) 109 | } 110 | }) 111 | 112 | t.Run("Clean", func(t *testing.T) { 113 | opts := &manager.Options{Verbose: env.InContainer} // Verbose in containers for debugging 114 | 115 | err := yumManager.Clean(opts) 116 | if err != nil { 117 | t.Errorf("Clean failed: %v", err) 118 | } else { 119 | t.Log("Clean operation completed successfully") 120 | } 121 | }) 122 | 123 | t.Run("Refresh", func(t *testing.T) { 124 | opts := &manager.Options{} 125 | 126 | err := yumManager.Refresh(opts) 127 | if err != nil { 128 | t.Errorf("Refresh failed: %v", err) 129 | } else { 130 | t.Log("Refresh operation completed successfully") 131 | } 132 | }) 133 | } 134 | 135 | // TestYumParsingWithRealOutput tests parsing with real YUM output 136 | func TestYumParsingWithRealOutput(t *testing.T) { 137 | env, err := testenv.GetTestEnvironment() 138 | if err != nil { 139 | t.Fatalf("Failed to get test environment: %v", err) 140 | } 141 | 142 | // Only run if we can capture real output 143 | if !env.InContainer { 144 | t.Skip("Real output parsing test only runs in containers") 145 | } 146 | 147 | // Test parsing with fixtures appropriate to current environment 148 | t.Run("ParseSearchOutput", func(t *testing.T) { 149 | fixturePath := env.GetFixturePath("yum", "search-vim") 150 | 151 | if data, err := os.ReadFile(fixturePath); err == nil { 152 | pm := NewPackageManager() 153 | packages := pm.ParseFindOutput(string(data), nil) 154 | 155 | if len(packages) == 0 { 156 | t.Error("Failed to parse any packages from fixture") 157 | } else { 158 | t.Logf("Parsed %d packages from %s", len(packages), fixturePath) 159 | } 160 | } else { 161 | t.Logf("No fixture available at %s, skipping", fixturePath) 162 | } 163 | }) 164 | } 165 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build build-all-arch test lint format fmt check install-tools \ 2 | test-docker test-docker-all test-docker-ubuntu test-docker-rocky test-docker-alma \ 3 | test-docker-clean test-fixtures test-unit test-integration test-env 4 | 5 | # Go parameters 6 | GOCMD=go 7 | GOBUILD=$(GOCMD) build 8 | GOTEST=$(GOCMD) test 9 | GOINSTALL=$(GOCMD) install 10 | BINARY_NAME=syspkg 11 | BINARY_OUTPUT=bin/$(BINARY_NAME) 12 | 13 | # Determine platform 14 | UNAME_S := $(shell uname -s) 15 | ifeq ($(UNAME_S),Linux) 16 | GOOS=linux 17 | endif 18 | ifeq ($(UNAME_S),Darwin) 19 | GOOS=darwin 20 | endif 21 | ifeq ($(UNAME_S),Windows_NT) 22 | GOOS=windows 23 | endif 24 | 25 | # Determine architecture 26 | UNAME_P := $(shell uname -m) 27 | ifeq ($(UNAME_P),x86_64) 28 | GOARCH=amd64 29 | endif 30 | ifeq ($(UNAME_P),aarch64) 31 | GOARCH=arm64 32 | endif 33 | 34 | all: test build 35 | 36 | build: lint install-tools 37 | GOOS=$(GOOS) GOARCH=$(GOARCH) $(GOBUILD) -o $(BINARY_OUTPUT) ./cmd/syspkg 38 | 39 | build-all-arch: lint install-tools 40 | GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_OUTPUT)_linux_amd64 ./cmd/syspkg 41 | GOOS=linux GOARCH=arm64 $(GOBUILD) -o $(BINARY_OUTPUT)_linux_arm64 ./cmd/syspkg 42 | GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINARY_OUTPUT)_darwin_amd64 ./cmd/syspkg 43 | GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_OUTPUT)_darwin_arm64 ./cmd/syspkg 44 | GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BINARY_OUTPUT)_windows_amd64.exe ./cmd/syspkg 45 | 46 | test: 47 | $(GOTEST) -v ./... 48 | $(GOTEST) -v -run ExampleGetOSInfo ./osinfo 49 | 50 | lint: 51 | go mod tidy 52 | golangci-lint run 53 | gofmt -s -w . 54 | 55 | format fmt: 56 | @echo "Running gofmt..." 57 | @gofmt -s -w . 58 | @echo "Running goimports..." 59 | @go install golang.org/x/tools/cmd/goimports@latest 60 | @goimports -w -local github.com/bluet/syspkg . 61 | @echo "Formatting complete!" 62 | 63 | check: 64 | @echo "Checking code formatting..." 65 | @if [ -n "$$(gofmt -l .)" ]; then \ 66 | echo "The following files need formatting:"; \ 67 | gofmt -l .; \ 68 | echo ""; \ 69 | echo "Run 'make format' to fix formatting"; \ 70 | exit 1; \ 71 | fi 72 | @echo "Checking go mod tidy..." 73 | @go mod tidy 74 | @if [ -n "$$(git status --porcelain go.mod go.sum)" ]; then \ 75 | echo "go.mod or go.sum needs updating"; \ 76 | echo "Run 'go mod tidy' and commit the changes"; \ 77 | exit 1; \ 78 | fi 79 | @echo "Running go vet..." 80 | @go vet ./... 81 | @echo "Running golangci-lint..." 82 | @golangci-lint run 83 | @echo "All checks passed!" 84 | 85 | install-tools: 86 | $(GOINSTALL) github.com/golangci/golangci-lint/cmd/golangci-lint@latest 87 | 88 | # Docker testing targets 89 | test-docker: 90 | @echo "Running tests in Docker containers..." 91 | docker-compose -f testing/docker/docker-compose.test.yml up --abort-on-container-exit --remove-orphans 92 | 93 | test-docker-ubuntu: 94 | @echo "Running Ubuntu APT tests..." 95 | docker-compose -f testing/docker/docker-compose.test.yml up ubuntu-apt-test --abort-on-container-exit 96 | 97 | test-docker-rocky: 98 | @echo "Running Rocky Linux YUM tests..." 99 | docker-compose -f testing/docker/docker-compose.test.yml up rockylinux-yum-test --abort-on-container-exit 100 | 101 | test-docker-alma: 102 | @echo "Running AlmaLinux YUM tests..." 103 | docker-compose -f testing/docker/docker-compose.test.yml up almalinux-yum-test --abort-on-container-exit 104 | 105 | # TODO: Enable when DNF support is implemented 106 | # test-docker-fedora: 107 | # @echo "Running Fedora DNF tests..." 108 | # docker-compose -f testing/docker/docker-compose.test.yml up fedora-dnf-test --abort-on-container-exit 109 | 110 | # TODO: Enable when APK support is implemented 111 | # test-docker-alpine: 112 | # @echo "Running Alpine APK tests..." 113 | # docker-compose -f testing/docker/docker-compose.test.yml up alpine-apk-test --abort-on-container-exit 114 | 115 | test-docker-all: test-docker 116 | 117 | # Generate test fixtures from different OS 118 | test-fixtures: 119 | @echo "Generating test fixtures from multiple OS..." 120 | @mkdir -p testing/fixtures/{apt,yum,dnf,apk} 121 | docker-compose -f testing/docker/docker-compose.test.yml up --abort-on-container-exit 122 | @echo "Test fixtures generated in testing/fixtures/" 123 | 124 | # Clean up Docker resources 125 | test-docker-clean: 126 | @echo "Cleaning up Docker test resources..." 127 | docker-compose -f testing/docker/docker-compose.test.yml down --volumes --remove-orphans 128 | docker system prune -f --filter "label=com.docker.compose.project=syspkg-test" 129 | 130 | # Unit tests only (no integration/system tests) 131 | test-unit: 132 | $(GOTEST) -v -tags=unit ./... 133 | 134 | # Integration tests (requires appropriate OS/package managers) 135 | test-integration: 136 | $(GOTEST) -v -tags=integration ./... 137 | 138 | # Environment-aware testing 139 | test-env: 140 | @echo "Running environment-aware tests..." 141 | @echo "OS: $$(go run ./testing/testenv/cmd/detect-env || echo 'Unknown')" 142 | $(GOTEST) -v -tags="unit,integration" ./... 143 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.6] - 2025-11-01 11 | 12 | ### Added 13 | - **YUM Package Manager Support**: Full support for YUM package manager (RHEL/CentOS/Rocky Linux/AlmaLinux) 14 | - Complete YUM implementation with all package operations (install, remove, upgrade, search, list) 15 | - Comprehensive test suite including unit, integration, and mock tests 16 | - YUM-specific exit code documentation and handling 17 | - Real-world fixture-based testing with Rocky Linux outputs 18 | - **ARM64 Architecture Support**: Docker testing now supports both AMD64 and ARM64 (Apple Silicon) 19 | - Automatic architecture detection in all Dockerfiles 20 | - Cross-platform development support for ARM64 machines 21 | - **Enhanced Testing Infrastructure**: 22 | - Multi-OS Docker testing support for Rocky Linux, AlmaLinux, Fedora, and Alpine 23 | - Comprehensive fixture-based testing framework 24 | - CommandRunner architecture for better testability 25 | - **Improved Documentation**: 26 | - Complete architecture documentation (ARCHITECTURE.md) 27 | - Exit code documentation for all package managers 28 | - Contributing guidelines (CONTRIBUTING.md) 29 | - Enhanced development workflows and CI/CD documentation 30 | 31 | ### Changed 32 | - Upgraded to Go 1.23/1.24 for CI/CD workflows 33 | - Enhanced CI/CD pipeline with multi-OS testing 34 | - Improved project structure and organization 35 | 36 | ### Fixed 37 | - Technical debt cleanup and APT Upgrade method fix 38 | - APT Upgrade method now correctly uses `apt install` for specific packages 39 | 40 | ## Recent Achievements ✅ 41 | 42 | ### Architecture & Code Quality 43 | - ✅ **CommandRunner Architecture**: Complete architectural consistency (Issue #20, PR #26) 44 | - ✅ **APT & YUM executeCommand Pattern**: Centralized command execution, eliminated code duplication 45 | - ✅ **Technical Debt Cleanup**: Fixed APT Upgrade method bug, removed misleading TODOs, verified no resource leaks 46 | 47 | ### Security Enhancements 48 | - ✅ **Security Enhancements**: Input validation for package names (Issue #23, PR #25) 49 | - ✅ **Command Injection Prevention**: Comprehensive ValidatePackageName implementation across all package managers 50 | 51 | ### Bug Fixes 52 | - ✅ **APT Exit Code Bug**: Fixed in commit 3751f45 - now properly propagates errors (Issue #21) 53 | - ✅ **Snap Exit Code Bug**: Fixed in commit 3751f45 - now properly handles usage errors (Issue #22) 54 | - ✅ **Flatpak Exit Code Bug**: Fixed in commit 3751f45 - now properly handles general errors (Issue #24) 55 | 56 | ## CI/CD Status 57 | 58 | | Workflow | Status | Description | 59 | | -------- | ------ | ----------- | 60 | | **Test and Coverage** | ✅ | Go 1.23/1.24 testing with coverage reporting | 61 | | **Lint and Format** | ✅ | golangci-lint, gofmt, go vet quality checks | 62 | | **Build** | ✅ | Multi-version build verification | 63 | | **Multi-OS Tests** | ✅ | Docker-based testing across Ubuntu, Rocky Linux, Alpine | 64 | | **Release Binaries** | ✅ | Cross-platform binary releases | 65 | 66 | ### Infrastructure Status 67 | - ✅ **Pre-commit hooks**: Automated code quality and security checks 68 | - ✅ **Go mod verification**: Dependency integrity validation 69 | - ✅ **Multi-OS compatibility**: Docker testing with Go 1.23.4 across distributions 70 | - ✅ **Fixture-based testing**: Real package manager output validation 71 | 72 | ## Active Development 73 | 74 | Current development focus areas (see [GitHub Issues](https://github.com/bluet/syspkg/issues) and [CLAUDE.md](CLAUDE.md) for detailed tracking): 75 | 76 | ### High Priority Pending 77 | - **Security scanning with Snyk** - Add to CI/CD pipeline 78 | - **CommandRunner migration** - Complete Snap and Flatpak integration (Issues #28, #29) 79 | 80 | ### Medium Priority Pending 81 | - **Test coverage improvements** - YUM gaps (Issue #32), Snap & Flatpak comprehensive suites 82 | - **CLI improvements** - Upgrade display (Issue #3), macOS apt conflict (Issue #2) 83 | - **Code quality** - Context support, custom error types, DRY principle improvements 84 | 85 | ### Low Priority Pending 86 | - **New package managers** - DNF, APK, Homebrew, Windows package managers 87 | - **Bug fixes** - APT multi-arch parsing (Issue #15), version parsing improvements 88 | 89 | ## Platform Support Status 90 | 91 | ### Currently Supported ✅ 92 | - **APT** (Ubuntu/Debian) - Full feature support 93 | - **YUM** (Rocky Linux/AlmaLinux/RHEL) - Full feature support 94 | - **Snap** (Universal packages) - Full feature support 95 | - **Flatpak** (Universal packages) - Full feature support 96 | 97 | ### In Development 🚧 98 | - **DNF** (Fedora/RHEL 9+) - Implementation in progress 99 | - **APK** (Alpine Linux) - Implementation in progress 100 | 101 | ### Planned 📋 102 | - **Homebrew** (macOS) - Planned for cross-platform expansion 103 | - **Chocolatey/Scoop/winget** (Windows) - Planned for Windows support 104 | - **Zypper** (openSUSE) - Lower priority -------------------------------------------------------------------------------- /testing/testenv/testenv.go: -------------------------------------------------------------------------------- 1 | // Package testenv provides utilities for detecting test environments and 2 | // determining which package managers should be tested based on the current OS. 3 | package testenv 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/bluet/syspkg/osinfo" 12 | ) 13 | 14 | // TestEnvironment represents the current testing environment 15 | type TestEnvironment struct { 16 | OS string 17 | Distribution string 18 | Version string 19 | InContainer bool 20 | AvailableManagers []string 21 | TestTags []string 22 | } 23 | 24 | // GetTestEnvironment detects the current test environment and returns 25 | // information about what should be tested 26 | func GetTestEnvironment() (*TestEnvironment, error) { 27 | osInfo, err := osinfo.GetOSInfo() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | env := &TestEnvironment{ 33 | OS: osInfo.Name, 34 | Distribution: osInfo.Distribution, 35 | Version: osInfo.Version, 36 | InContainer: os.Getenv("IN_CONTAINER") == "true", 37 | } 38 | 39 | // Determine available package managers based on OS 40 | env.AvailableManagers = getAvailableManagers(osInfo) 41 | env.TestTags = getRecommendedTestTags(env) 42 | 43 | return env, nil 44 | } 45 | 46 | // getAvailableManagers returns the list of package managers that should 47 | // be available on the given OS 48 | func getAvailableManagers(osInfo *osinfo.OSInfo) []string { 49 | var managers []string 50 | 51 | switch osInfo.Name { 52 | case "linux": 53 | switch strings.ToLower(osInfo.Distribution) { 54 | case "ubuntu", "debian": 55 | managers = []string{"apt"} 56 | // Flatpak available but requires setup 57 | if os.Getenv("IN_CONTAINER") != "true" { 58 | managers = append(managers, "flatpak", "snap") 59 | } 60 | 61 | case "fedora": 62 | managers = []string{"dnf"} 63 | if os.Getenv("IN_CONTAINER") != "true" { 64 | managers = append(managers, "flatpak") 65 | } 66 | 67 | case "rocky", "almalinux", "centos": 68 | // Determine YUM vs DNF based on version 69 | // Extract major version number 70 | versionParts := strings.Split(osInfo.Version, ".") 71 | if len(versionParts) > 0 { 72 | majorVersion, err := strconv.Atoi(versionParts[0]) 73 | if err == nil { 74 | if majorVersion >= 9 { 75 | // RHEL/Rocky/Alma 9+ uses DNF 76 | managers = []string{"dnf"} 77 | } else if majorVersion >= 8 { 78 | // RHEL/Rocky/Alma 8 uses YUM 79 | managers = []string{"yum"} 80 | } 81 | } 82 | } 83 | 84 | case "alpine": 85 | managers = []string{"apk"} 86 | 87 | case "arch": 88 | managers = []string{"pacman"} 89 | } 90 | 91 | case "darwin": 92 | managers = []string{"brew"} 93 | 94 | case "windows": 95 | managers = []string{"choco", "scoop", "winget"} 96 | } 97 | 98 | return managers 99 | } 100 | 101 | // getRecommendedTestTags returns the recommended test tags for the environment 102 | func getRecommendedTestTags(env *TestEnvironment) []string { 103 | tags := []string{"unit"} // Always run unit tests 104 | 105 | if env.InContainer { 106 | tags = append(tags, "integration") 107 | // Add specific package manager tags 108 | tags = append(tags, env.AvailableManagers...) 109 | } else { 110 | // Native environment can run system tests 111 | tags = append(tags, "integration", "system") 112 | } 113 | 114 | return tags 115 | } 116 | 117 | // ShouldSkipTest determines if a test should be skipped based on environment 118 | func (env *TestEnvironment) ShouldSkipTest(requiredPM string) (bool, string) { 119 | // Check if package manager is available in this environment 120 | for _, available := range env.AvailableManagers { 121 | if available == requiredPM { 122 | return false, "" 123 | } 124 | } 125 | 126 | return true, "Package manager " + requiredPM + " not available on " + 127 | env.OS + "/" + env.Distribution 128 | } 129 | 130 | // GetFixturePath returns the appropriate fixture path for the current OS 131 | func (env *TestEnvironment) GetFixturePath(pm, operation string) string { 132 | // Use filepath.Join for proper path construction 133 | baseDir := filepath.Join("testing", "fixtures", pm) 134 | 135 | // Try OS-specific fixtures first 136 | osSpecificFile := operation + "-" + env.Distribution + ".txt" 137 | osSpecificPath := filepath.Join(baseDir, osSpecificFile) 138 | 139 | // Check if OS-specific fixture exists 140 | if info, err := os.Stat(osSpecificPath); err == nil && !info.IsDir() { 141 | return osSpecificPath 142 | } 143 | 144 | // Fall back to generic fixtures 145 | genericFile := operation + ".txt" 146 | return filepath.Join(baseDir, genericFile) 147 | } 148 | 149 | // IsContainerEnvironment returns true if running in a container 150 | func IsContainerEnvironment() bool { 151 | return os.Getenv("IN_CONTAINER") == "true" 152 | } 153 | 154 | // GetTestPackageManager returns the package manager to test from environment 155 | func GetTestPackageManager() string { 156 | return os.Getenv("TEST_PACKAGE_MANAGER") 157 | } 158 | 159 | // GetTestOS returns the OS being tested from environment 160 | func GetTestOS() string { 161 | return os.Getenv("TEST_OS") 162 | } 163 | -------------------------------------------------------------------------------- /testing/docker/test-strategy.md: -------------------------------------------------------------------------------- 1 | # Docker Testing Strategy for Package Managers 2 | 3 | ## What Works Well in Docker 4 | 5 | ### ✅ APT Testing 6 | ```bash 7 | docker run --rm -v $(PWD):/workspace ubuntu:22.04 bash -c " 8 | apt update 9 | apt search vim | head -20 > /workspace/testing/fixtures/apt/search-output.txt 10 | apt show vim > /workspace/testing/fixtures/apt/show-output.txt 11 | dpkg -l | head -20 > /workspace/testing/fixtures/apt/list-output.txt 12 | " 13 | ``` 14 | 15 | ### ✅ DNF/YUM Testing (Fedora) 16 | ```bash 17 | docker run --rm -v $(PWD):/workspace fedora:38 bash -c " 18 | dnf search vim | head -20 > /workspace/testing/fixtures/dnf/search-output.txt 19 | dnf info vim > /workspace/testing/fixtures/dnf/info-output.txt 20 | " 21 | ``` 22 | 23 | ### ✅ APK Testing (Alpine) 24 | ```bash 25 | docker run --rm -v $(PWD):/workspace alpine:3.18 sh -c " 26 | apk search vim > /workspace/testing/fixtures/apk/search-output.txt 27 | apk info vim > /workspace/testing/fixtures/apk/info-output.txt 28 | " 29 | ``` 30 | 31 | ### ✅ Flatpak Testing (Limited) 32 | ```bash 33 | # Flatpak can list/search without full functionality 34 | docker run --rm -v $(PWD):/workspace ubuntu:22.04 bash -c " 35 | apt update && apt install -y flatpak 36 | flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 37 | flatpak search vim > /workspace/testing/fixtures/flatpak/search-output.txt || true 38 | " 39 | ``` 40 | 41 | ## What Doesn't Work in Docker 42 | 43 | ### ❌ Snap Operations 44 | - `snap install/remove` - Requires snapd daemon 45 | - `snap list` - Requires running snapd 46 | - `snap find` - Requires snapd connection 47 | 48 | ### ❌ System Package Installation 49 | - Installing actual packages (without --privileged) 50 | - System-level operations 51 | - Hardware access 52 | 53 | ## Recommended Test Approach 54 | 55 | ### 1. **Docker for Fixture Generation** 56 | Use Docker to generate real command outputs: 57 | 58 | ```go 59 | //go:generate docker run --rm -v $(PWD):/workspace ubuntu:22.04 bash -c "apt update && apt search vim > /workspace/testing/fixtures/apt/search-vim.txt" 60 | ``` 61 | 62 | ### 2. **Mock Testing for CI** 63 | ```go 64 | // +build !integration 65 | 66 | func TestAptInstall(t *testing.T) { 67 | executor := &MockExecutor{ 68 | outputs: map[string]string{ 69 | "apt install -y vim": readFixture("apt/install-vim.txt"), 70 | }, 71 | } 72 | 73 | pm := apt.NewWithExecutor(executor) 74 | packages, err := pm.Install([]string{"vim"}, &manager.Options{AssumeYes: true}) 75 | 76 | assert.NoError(t, err) 77 | assert.Len(t, packages, 1) 78 | } 79 | ``` 80 | 81 | ### 3. **Integration Testing Matrix** 82 | ```yaml 83 | # .github/workflows/integration.yml 84 | jobs: 85 | apt-test: 86 | runs-on: ubuntu-latest 87 | steps: 88 | - run: | 89 | sudo apt update 90 | sudo apt install -y vim 91 | go test -tags=integration ./manager/apt 92 | 93 | snap-test: 94 | runs-on: ubuntu-latest 95 | steps: 96 | - run: | 97 | sudo snap install hello-world 98 | go test -tags=integration ./manager/snap 99 | 100 | flatpak-test: 101 | runs-on: ubuntu-latest 102 | steps: 103 | - run: | 104 | sudo apt install -y flatpak 105 | go test -tags=integration ./manager/flatpak 106 | ``` 107 | 108 | ## Docker Test Implementation 109 | 110 | ### Base Test Runner 111 | ```go 112 | package testing 113 | 114 | import ( 115 | "context" 116 | "fmt" 117 | "os" 118 | "os/exec" 119 | "time" 120 | ) 121 | 122 | type DockerTestRunner struct { 123 | Image string 124 | Cmd string 125 | Timeout time.Duration 126 | } 127 | 128 | func (d *DockerTestRunner) CaptureOutput(outputFile string) error { 129 | timeout := d.Timeout 130 | if timeout == 0 { 131 | timeout = 30 * time.Second 132 | } 133 | 134 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 135 | defer cancel() 136 | 137 | workDir, err := os.Getwd() 138 | if err != nil { 139 | return fmt.Errorf("failed to get working directory: %w", err) 140 | } 141 | 142 | cmd := exec.CommandContext(ctx, "docker", "run", "--rm", 143 | "-v", fmt.Sprintf("%s:/workspace", workDir), 144 | d.Image, 145 | "bash", "-c", d.Cmd + " > /workspace/" + outputFile) 146 | 147 | return cmd.Run() 148 | } 149 | ``` 150 | 151 | ### Usage in Tests 152 | ```go 153 | func TestCaptureRealOutputs(t *testing.T) { 154 | if os.Getenv("CAPTURE_FIXTURES") != "true" { 155 | t.Skip("Skipping fixture capture") 156 | } 157 | 158 | runner := &DockerTestRunner{ 159 | Image: "ubuntu:22.04", 160 | Cmd: "apt update && apt search golang", 161 | Timeout: 60 * time.Second, // Optional: defaults to 30s 162 | } 163 | 164 | err := runner.CaptureOutput("testing/fixtures/apt/search-golang.txt") 165 | assert.NoError(t, err) 166 | } 167 | ``` 168 | 169 | ## Summary 170 | 171 | - **Use Docker for**: Capturing real outputs, testing parsers, OS detection 172 | - **Don't use Docker for**: snap operations, actual installations, privileged operations 173 | - **Alternative for snap**: Mock data or native CI runners 174 | -------------------------------------------------------------------------------- /manager/command_runner_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestMockCommandRunner(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | commands map[string][]byte 15 | errors map[string]error 16 | testCommand string 17 | testArgs []string 18 | expectedOutput []byte 19 | expectedError error 20 | }{ 21 | { 22 | name: "successful command execution", 23 | commands: map[string][]byte{ 24 | "rpm -q vim": []byte("vim-enhanced-8.0.1763-19.el8_6.4.x86_64\n"), 25 | }, 26 | testCommand: "rpm", 27 | testArgs: []string{"-q", "vim"}, 28 | expectedOutput: []byte("vim-enhanced-8.0.1763-19.el8_6.4.x86_64\n"), 29 | expectedError: nil, 30 | }, 31 | { 32 | name: "command returns error", 33 | errors: map[string]error{ 34 | "rpm -q nonexistent": errors.New("package nonexistent is not installed"), 35 | }, 36 | testCommand: "rpm", 37 | testArgs: []string{"-q", "nonexistent"}, 38 | expectedOutput: nil, 39 | expectedError: errors.New("package nonexistent is not installed"), 40 | }, 41 | { 42 | name: "command not mocked returns error", 43 | commands: map[string][]byte{}, 44 | testCommand: "unknown", 45 | testArgs: []string{"command"}, 46 | expectedOutput: nil, 47 | expectedError: errors.New("no mock found for command: unknown command"), 48 | }, 49 | } 50 | 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | runner := NewMockCommandRunner() 54 | 55 | // Set up mocked commands using the proper methods 56 | for cmd, output := range tt.commands { 57 | // Parse the command string to extract name and args 58 | parts := strings.Fields(cmd) 59 | if len(parts) == 0 { 60 | t.Errorf("Invalid empty command string: %q", cmd) 61 | continue 62 | } 63 | name := parts[0] 64 | args := parts[1:] 65 | runner.AddCommand(name, args, output, nil) 66 | } 67 | for cmd, err := range tt.errors { 68 | // Parse the command string to extract name and args 69 | parts := strings.Fields(cmd) 70 | if len(parts) == 0 { 71 | t.Errorf("Invalid empty command string: %q", cmd) 72 | continue 73 | } 74 | name := parts[0] 75 | args := parts[1:] 76 | runner.AddCommand(name, args, nil, err) 77 | } 78 | 79 | // Test the command execution 80 | output, err := runner.Run(tt.testCommand, tt.testArgs...) 81 | 82 | // Verify results 83 | if string(output) != string(tt.expectedOutput) { 84 | t.Errorf("Expected output %q, got %q", string(tt.expectedOutput), string(output)) 85 | } 86 | 87 | if (err == nil) != (tt.expectedError == nil) { 88 | t.Errorf("Expected error %v, got %v", tt.expectedError, err) 89 | } 90 | 91 | if err != nil && tt.expectedError != nil && err.Error() != tt.expectedError.Error() { 92 | t.Errorf("Expected error %q, got %q", tt.expectedError.Error(), err.Error()) 93 | } 94 | }) 95 | } 96 | } 97 | 98 | func TestMockCommandRunnerAddMethods(t *testing.T) { 99 | runner := NewMockCommandRunner() 100 | 101 | // Test AddCommand 102 | runner.AddCommand("rpm", []string{"-q", "vim"}, []byte("vim-8.0.1763\n"), nil) 103 | output, err := runner.Run("rpm", "-q", "vim") 104 | 105 | if err != nil { 106 | t.Errorf("Unexpected error: %v", err) 107 | } 108 | if string(output) != "vim-8.0.1763\n" { 109 | t.Errorf("Expected 'vim-8.0.1763\\n', got %q", string(output)) 110 | } 111 | 112 | // Test AddError 113 | testErr := errors.New("test error") 114 | runner.AddError("rpm", []string{"-q", "missing"}, testErr) 115 | _, err = runner.Run("rpm", "-q", "missing") 116 | 117 | if err == nil { 118 | t.Error("Expected error, got nil") 119 | } 120 | if err.Error() != "test error" { 121 | t.Errorf("Expected 'test error', got %q", err.Error()) 122 | } 123 | } 124 | 125 | func TestDefaultCommandRunner(t *testing.T) { 126 | runner := NewDefaultCommandRunner() 127 | 128 | // Test that timeout is set 129 | if runner.Timeout != 30*time.Second { 130 | t.Errorf("Expected timeout 30s, got %v", runner.Timeout) 131 | } 132 | 133 | // Test a simple command that should exist on most systems 134 | output, err := runner.Run("echo", "test") 135 | if err != nil { 136 | t.Errorf("Unexpected error: %v", err) 137 | } 138 | // Note: With LC_ALL=C prepended, output should still be "test\n" 139 | if string(output) != "test\n" { 140 | t.Errorf("Expected 'test\\n', got %q", string(output)) 141 | } 142 | } 143 | 144 | func TestDefaultCommandRunnerWithContext(t *testing.T) { 145 | runner := NewDefaultCommandRunner() 146 | 147 | // Test with a normal context 148 | ctx := context.Background() 149 | output, err := runner.RunContext(ctx, "echo", []string{"test"}) 150 | if err != nil { 151 | t.Errorf("Unexpected error: %v", err) 152 | } 153 | if string(output) != "test\n" { 154 | t.Errorf("Expected 'test\\n', got %q", string(output)) 155 | } 156 | 157 | // Test with a cancelled context 158 | cancelledCtx, cancel := context.WithCancel(context.Background()) 159 | cancel() // Cancel immediately 160 | 161 | _, err = runner.RunContext(cancelledCtx, "sleep", []string{"10"}) 162 | if err == nil { 163 | t.Error("Expected error due to cancelled context") 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /testing/docker/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | # Multi-OS testing with Docker Compose 4 | # Usage: docker-compose -f testing/docker/docker-compose.test.yml up 5 | 6 | services: 7 | # Ubuntu - APT testing 8 | ubuntu-apt-test: 9 | build: 10 | context: ../.. 11 | dockerfile: testing/docker/ubuntu.Dockerfile 12 | environment: 13 | - IN_CONTAINER=true 14 | - TEST_OS=ubuntu 15 | - TEST_OS_VERSION=22.04 16 | - TEST_PACKAGE_MANAGER=apt 17 | - TEST_TAGS=unit,integration,apt 18 | volumes: 19 | - ../..:/workspace 20 | working_dir: /workspace 21 | command: > 22 | bash -c " 23 | echo 'Running Ubuntu APT tests...' && 24 | go test -v -tags='unit integration apt' ./manager/apt ./osinfo && 25 | echo 'Generating APT fixtures...' && 26 | apt update && 27 | apt search vim > testing/fixtures/apt/search-vim-ubuntu22.txt 2>/dev/null || true && 28 | apt show vim > testing/fixtures/apt/show-vim-ubuntu22.txt 2>/dev/null || true 29 | " 30 | 31 | # Rocky Linux 8 - YUM testing 32 | rockylinux-yum-test: 33 | build: 34 | context: ../.. 35 | dockerfile: testing/docker/rockylinux.Dockerfile 36 | environment: 37 | - IN_CONTAINER=true 38 | - TEST_OS=rockylinux 39 | - TEST_OS_VERSION=8 40 | - TEST_PACKAGE_MANAGER=yum 41 | - TEST_TAGS=unit,integration,yum 42 | volumes: 43 | - ../..:/workspace 44 | working_dir: /workspace 45 | command: > 46 | bash -c " 47 | echo 'Running Rocky Linux YUM tests...' && 48 | go test -v -tags='unit integration yum' ./manager/yum ./osinfo && 49 | echo 'Generating YUM fixtures...' && 50 | yum search vim > testing/fixtures/yum/search-vim-rocky8.txt 2>/dev/null || true && 51 | yum info vim-enhanced > testing/fixtures/yum/info-vim-rocky8.txt 2>/dev/null || true && 52 | yum list --installed > testing/fixtures/yum/list-installed-rocky8.txt 2>/dev/null || true 53 | " 54 | 55 | # AlmaLinux 8 - YUM testing 56 | almalinux-yum-test: 57 | build: 58 | context: ../.. 59 | dockerfile: testing/docker/almalinux.Dockerfile 60 | environment: 61 | - IN_CONTAINER=true 62 | - TEST_OS=almalinux 63 | - TEST_OS_VERSION=8 64 | - TEST_PACKAGE_MANAGER=yum 65 | - TEST_TAGS=unit,integration,yum 66 | volumes: 67 | - ../..:/workspace 68 | working_dir: /workspace 69 | command: > 70 | bash -c " 71 | echo 'Running AlmaLinux YUM tests...' && 72 | go test -v -tags='unit integration yum' ./manager/yum ./osinfo && 73 | echo 'Generating YUM fixtures...' && 74 | yum search vim > testing/fixtures/yum/search-vim-alma8.txt 2>/dev/null || true && 75 | yum info vim-enhanced > testing/fixtures/yum/info-vim-alma8.txt 2>/dev/null || true 76 | " 77 | 78 | # TODO: Enable when DNF support is implemented 79 | # fedora-dnf-test: 80 | # build: 81 | # context: ../.. 82 | # dockerfile: testing/docker/fedora.Dockerfile 83 | # environment: 84 | # - IN_CONTAINER=true 85 | # - TEST_OS=fedora 86 | # - TEST_OS_VERSION=39 87 | # - TEST_PACKAGE_MANAGER=dnf 88 | # - TEST_TAGS=unit,integration,dnf 89 | # volumes: 90 | # - ../..:/workspace 91 | # working_dir: /workspace 92 | # command: > 93 | # bash -c " 94 | # echo 'Running Fedora DNF tests...' && 95 | # go test -v -tags='unit integration dnf' ./manager/dnf ./osinfo 2>/dev/null || echo 'DNF manager not implemented yet' && 96 | # echo 'Generating DNF fixtures...' && 97 | # dnf search vim > testing/fixtures/dnf/search-vim-fedora39.txt 2>/dev/null || true && 98 | # dnf info vim > testing/fixtures/dnf/info-vim-fedora39.txt 2>/dev/null || true 99 | # " 100 | 101 | # TODO: Enable when APK support is implemented 102 | # alpine-apk-test: 103 | # build: 104 | # context: ../.. 105 | # dockerfile: testing/docker/alpine.Dockerfile 106 | # environment: 107 | # - IN_CONTAINER=true 108 | # - TEST_OS=alpine 109 | # - TEST_OS_VERSION=3.18 110 | # - TEST_PACKAGE_MANAGER=apk 111 | # - TEST_TAGS=unit,integration,apk 112 | # volumes: 113 | # - ../..:/workspace 114 | # working_dir: /workspace 115 | # command: > 116 | # sh -c " 117 | # echo 'Running Alpine APK tests...' && 118 | # go test -v -tags='unit integration apk' ./manager/apk ./osinfo 2>/dev/null || echo 'APK manager not implemented yet' && 119 | # echo 'Generating APK fixtures...' && 120 | # apk update && 121 | # apk search vim > testing/fixtures/apk/search-vim-alpine.txt 2>/dev/null || true && 122 | # apk info vim > testing/fixtures/apk/info-vim-alpine.txt 2>/dev/null || true 123 | # " 124 | 125 | # Test runner that runs all tests in parallel 126 | test-all: 127 | image: ubuntu:22.04 128 | depends_on: 129 | - ubuntu-apt-test 130 | - rockylinux-yum-test 131 | - almalinux-yum-test 132 | # - fedora-dnf-test # TODO: Enable when DNF support is implemented 133 | # - alpine-apk-test # TODO: Enable when APK support is implemented 134 | volumes: 135 | - ../..:/workspace 136 | working_dir: /workspace 137 | command: > 138 | bash -c " 139 | echo 'All OS-specific tests completed!' 140 | " 141 | -------------------------------------------------------------------------------- /syspkg_test.go: -------------------------------------------------------------------------------- 1 | // syspkg/syspkg_test.go 2 | package syspkg_test 3 | 4 | import ( 5 | "log" 6 | "testing" 7 | 8 | "github.com/bluet/syspkg" 9 | "github.com/bluet/syspkg/osinfo" 10 | ) 11 | 12 | func TestNewPackageManager(t *testing.T) { 13 | 14 | // get system type 15 | OSInfo, err := osinfo.GetOSInfo() 16 | if err != nil { 17 | t.Fatalf("GetOSInfo() error: %+v", err) 18 | } 19 | 20 | log.Printf("OSInfo: %+v", OSInfo) 21 | 22 | s, err := syspkg.New(syspkg.IncludeOptions{ 23 | AllAvailable: true, 24 | }) 25 | if err != nil { 26 | t.Fatalf("NewPackageManager() error: %+v", err) 27 | } 28 | 29 | pms, err := s.FindPackageManagers(syspkg.IncludeOptions{ 30 | AllAvailable: true, 31 | }) 32 | if err != nil { 33 | t.Fatalf("FindPackageManagers() error: %+v", err) 34 | } 35 | 36 | log.Printf("pms: %+v", pms) 37 | 38 | // if we are on ubuntu, debian, mint, PopOS, elementary, Zorin, ChromeOS or any other debian-based distro, we should have apt, snap, or flatpak 39 | // if we are on fedora, centos, rhel, rockylinux, almalinux, amazon linux, oracle linux, scientific linux, or cloudlinux, we should have dnf or yum 40 | // if we are on opensuse, we should have zypper 41 | // if we are on alpine, we should have apk 42 | // if we are on arch, we should have pacman 43 | // if we are on gentoo, we should have emerge 44 | // if we are on slackware, we should have slackpkg 45 | // if we are on void, we should have xbps 46 | // if we are on solus, we should have eopkg 47 | // if we are on freebsd, dragonfly, or termux, we should have pkg 48 | // if we are on openbsd or netbsd, we should have pkg_add 49 | // if we are on macos, we should have brew 50 | // if we are on windows, we should have chocolatey or scoop or winget 51 | // if we are on android, we should have f-droid 52 | // if we are on ios, we should have cydia 53 | // if we are on any other distro, we should have nothing 54 | 55 | if OSInfo.Distribution == "ubuntu" || OSInfo.Distribution == "debian" || OSInfo.Distribution == "mint" || OSInfo.Distribution == "PopOS" || OSInfo.Distribution == "elementary" || OSInfo.Distribution == "Zorin" || OSInfo.Distribution == "ChromeOS" { 56 | pm, err := s.GetPackageManager("apt") 57 | 58 | if err != nil && pm == nil { 59 | pm, err := s.GetPackageManager("snap") 60 | 61 | if err != nil && pm == nil { 62 | pm, err := s.GetPackageManager("flatpak") 63 | 64 | if err != nil && pm == nil { 65 | t.Fatalf("apt, snap, or flatpak package manager not found") 66 | } 67 | } 68 | } 69 | } else if OSInfo.Distribution == "fedora" || OSInfo.Distribution == "centos" || OSInfo.Distribution == "rhel" || OSInfo.Distribution == "rockylinux" || OSInfo.Distribution == "rocky" || OSInfo.Distribution == "almalinux" || OSInfo.Distribution == "amazon linux" || OSInfo.Distribution == "oracle linux" || OSInfo.Distribution == "scientific linux" || OSInfo.Distribution == "cloudlinux" { 70 | pm, err := s.GetPackageManager("dnf") 71 | if err != nil && pm == nil { 72 | pm, err := s.GetPackageManager("yum") 73 | if err != nil && pm == nil { 74 | t.Fatalf("dnf or yum package manager not found") 75 | } 76 | } 77 | } else if OSInfo.Distribution == "opensuse" { 78 | pm, err := s.GetPackageManager("zypper") 79 | if err != nil && pm == nil { 80 | t.Fatalf("zypper package manager not found") 81 | } 82 | } else if OSInfo.Distribution == "alpine" { 83 | pm, err := s.GetPackageManager("apk") 84 | if err != nil && pm == nil { 85 | t.Fatalf("apk package manager not found") 86 | } 87 | } else if OSInfo.Distribution == "arch" { 88 | pm, err := s.GetPackageManager("pacman") 89 | if err != nil && pm == nil { 90 | t.Fatalf("pacman package manager not found") 91 | } 92 | } else if OSInfo.Distribution == "gentoo" { 93 | pm, err := s.GetPackageManager("emerge") 94 | if err != nil && pm == nil { 95 | t.Fatalf("emerge package manager not found") 96 | } 97 | } else if OSInfo.Distribution == "slackware" { 98 | pm, err := s.GetPackageManager("slackpkg") 99 | if err != nil && pm == nil { 100 | t.Fatalf("slackpkg package manager not found") 101 | } 102 | } else if OSInfo.Distribution == "void" { 103 | pm, err := s.GetPackageManager("xbps") 104 | if err != nil && pm == nil { 105 | t.Fatalf("xbps package manager not found") 106 | } 107 | } else if OSInfo.Distribution == "solus" { 108 | pm, err := s.GetPackageManager("eopkg") 109 | if err != nil && pm == nil { 110 | t.Fatalf("eopkg package manager not found") 111 | } 112 | } else if OSInfo.Distribution == "freebsd" || OSInfo.Distribution == "dragonfly" || OSInfo.Distribution == "termux" { 113 | pm, err := s.GetPackageManager("pkg") 114 | if err != nil && pm == nil { 115 | t.Fatalf("pkg package manager not found") 116 | } 117 | } else if OSInfo.Distribution == "openbsd" || OSInfo.Distribution == "netbsd" { 118 | pm, err := s.GetPackageManager("pkg_add") 119 | if err != nil && pm == nil { 120 | t.Fatalf("pkg_add package manager not found") 121 | } 122 | } else { 123 | // For other OSes (including macOS, Windows, etc.), we currently only support 124 | // apt, flatpak, and snap. These may or may not be available on any given system. 125 | // Just log what we found for debugging purposes. 126 | log.Printf("Found %d package managers: %v", len(pms), getPackageManagerNames(pms)) 127 | 128 | // Don't fail the test - package manager availability varies by system 129 | // and installation method (e.g., apt can be installed on macOS via Homebrew) 130 | } 131 | 132 | // if manager == nil { 133 | // t.Fatal("NewPackageManager() returned a nil manager") 134 | // } 135 | } 136 | 137 | // getPackageManagerNames returns a slice of package manager names from the map 138 | func getPackageManagerNames(pms map[string]syspkg.PackageManager) []string { 139 | names := make([]string, 0, len(pms)) 140 | for name := range pms { 141 | names = append(names, name) 142 | } 143 | return names 144 | } 145 | -------------------------------------------------------------------------------- /manager/yum/yum_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package yum_test 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | 10 | "github.com/bluet/syspkg/manager" 11 | "github.com/bluet/syspkg/manager/yum" 12 | "github.com/bluet/syspkg/testing/testenv" 13 | ) 14 | 15 | // TestYUMOperations_Integration tests YUM operations with real command execution 16 | // These tests are skipped unless YUM is available on the system 17 | func TestYUMOperations_Integration(t *testing.T) { 18 | // Check if we should run YUM tests 19 | env, err := testenv.GetTestEnvironment() 20 | if err != nil { 21 | t.Fatalf("Failed to get test environment: %v", err) 22 | } 23 | 24 | if skip, reason := env.ShouldSkipTest("yum"); skip { 25 | t.Skip(reason) 26 | } 27 | 28 | pm := &yum.PackageManager{} 29 | 30 | // Verify YUM is available 31 | if !pm.IsAvailable() { 32 | t.Skip("YUM is not available on this system") 33 | } 34 | 35 | t.Run("Find", func(t *testing.T) { 36 | // Search for a package that should exist in repos 37 | packages, err := pm.Find([]string{"bash"}, &manager.Options{}) 38 | if err != nil { 39 | t.Fatalf("Find failed: %v", err) 40 | } 41 | 42 | if len(packages) == 0 { 43 | t.Error("Expected to find bash package") 44 | } 45 | 46 | // Verify the Find() enhancement works 47 | for _, pkg := range packages { 48 | if pkg.Name == "bash" { 49 | // bash should be installed on any Linux system 50 | if pkg.Status != manager.PackageStatusInstalled { 51 | t.Errorf("bash should be installed, got status: %s", pkg.Status) 52 | } 53 | if pkg.Version == "" { 54 | t.Error("Installed bash should have version") 55 | } 56 | return 57 | } 58 | } 59 | t.Error("bash package not found in results") 60 | }) 61 | 62 | t.Run("ListInstalled", func(t *testing.T) { 63 | packages, err := pm.ListInstalled(&manager.Options{}) 64 | if err != nil { 65 | t.Fatalf("ListInstalled failed: %v", err) 66 | } 67 | 68 | if len(packages) == 0 { 69 | t.Error("Expected installed packages on system") 70 | } 71 | 72 | // All packages should have installed status 73 | for _, pkg := range packages { 74 | if pkg.Status != manager.PackageStatusInstalled { 75 | t.Errorf("Package %s should have status installed, got %s", 76 | pkg.Name, pkg.Status) 77 | } 78 | if pkg.Version == "" { 79 | t.Errorf("Package %s should have version", pkg.Name) 80 | } 81 | } 82 | }) 83 | 84 | t.Run("GetPackageInfo", func(t *testing.T) { 85 | // Test with a package that should be installed 86 | pkg, err := pm.GetPackageInfo("rpm", &manager.Options{}) 87 | if err != nil { 88 | t.Fatalf("GetPackageInfo failed: %v", err) 89 | } 90 | 91 | if pkg.Name != "rpm" { 92 | t.Errorf("Expected package name rpm, got %s", pkg.Name) 93 | } 94 | 95 | // rpm should always be installed on YUM-based systems 96 | if pkg.Status != manager.PackageStatusInstalled { 97 | t.Errorf("rpm should be installed, got status: %s", pkg.Status) 98 | } 99 | }) 100 | 101 | t.Run("Clean", func(t *testing.T) { 102 | // Test clean operation (safe to run) 103 | err := pm.Clean(&manager.Options{DryRun: true}) 104 | if err != nil { 105 | t.Errorf("Clean (dry run) failed: %v", err) 106 | } 107 | }) 108 | 109 | t.Run("Refresh", func(t *testing.T) { 110 | // Skip refresh in CI to avoid network operations 111 | if os.Getenv("CI") == "true" { 112 | t.Skip("Skipping refresh in CI environment") 113 | } 114 | 115 | err := pm.Refresh(&manager.Options{DryRun: true}) 116 | if err != nil { 117 | t.Errorf("Refresh (dry run) failed: %v", err) 118 | } 119 | }) 120 | } 121 | 122 | // TestYUMParsers_UnitWithFixtures tests parser functions with real YUM output fixtures 123 | // These are pure unit tests that don't require YUM to be installed 124 | func TestYUMParsers_UnitWithFixtures(t *testing.T) { 125 | t.Run("ParseFindOutput", func(t *testing.T) { 126 | fixture := loadFixture(t, "search-vim-rocky8.txt") 127 | pm := yum.NewPackageManager() 128 | packages := pm.ParseFindOutput(fixture, &manager.Options{}) 129 | 130 | if len(packages) != 5 { 131 | t.Errorf("Expected 5 packages, got %d", len(packages)) 132 | } 133 | 134 | // Verify parser limitations are documented 135 | for _, pkg := range packages { 136 | if pkg.Status != manager.PackageStatusAvailable { 137 | t.Errorf("Parser should return all as available, got %s", pkg.Status) 138 | } 139 | if pkg.Version != "" { 140 | t.Errorf("Parser should not set version from search output") 141 | } 142 | } 143 | }) 144 | 145 | t.Run("ParseListInstalledOutput", func(t *testing.T) { 146 | fixture := loadFixture(t, "list-installed-minimal-rocky8.txt") 147 | packages := yum.ParseListInstalledOutput(fixture, &manager.Options{}) 148 | 149 | if len(packages) == 0 { 150 | t.Error("Expected packages from list installed output") 151 | } 152 | 153 | // All should be installed with versions 154 | for _, pkg := range packages { 155 | if pkg.Status != manager.PackageStatusInstalled { 156 | t.Errorf("Package %s should be installed", pkg.Name) 157 | } 158 | if pkg.Version == "" { 159 | t.Errorf("Package %s should have version", pkg.Name) 160 | } 161 | } 162 | }) 163 | 164 | t.Run("ParsePackageInfoOutput", func(t *testing.T) { 165 | // Test installed package 166 | fixture := loadFixture(t, "info-vim-installed-rocky8.txt") 167 | pkg := yum.ParsePackageInfoOutput(fixture, &manager.Options{}) 168 | 169 | if pkg.Name != "vim-enhanced" { 170 | t.Errorf("Expected vim-enhanced, got %s", pkg.Name) 171 | } 172 | if pkg.Status != manager.PackageStatusInstalled { 173 | t.Errorf("Expected installed status, got %s", pkg.Status) 174 | } 175 | 176 | // Test available package 177 | fixture = loadFixture(t, "info-nginx-rocky8.txt") 178 | pkg = yum.ParsePackageInfoOutput(fixture, &manager.Options{}) 179 | 180 | if pkg.Status != manager.PackageStatusAvailable { 181 | t.Errorf("Expected available status, got %s", pkg.Status) 182 | } 183 | }) 184 | } 185 | 186 | // loadFixture is defined in behavior_test.go in the same package 187 | -------------------------------------------------------------------------------- /manager/security_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestValidatePackageName(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input string 12 | wantErr bool 13 | errMsg string 14 | }{ 15 | // Valid package names 16 | { 17 | name: "simple package name", 18 | input: "vim", 19 | wantErr: false, 20 | }, 21 | { 22 | name: "package with version", 23 | input: "python3.8", 24 | wantErr: false, 25 | }, 26 | { 27 | name: "package with dash", 28 | input: "gcc-9-base", 29 | wantErr: false, 30 | }, 31 | { 32 | name: "package with underscore", 33 | input: "libc6_dev", 34 | wantErr: false, 35 | }, 36 | { 37 | name: "package with plus", 38 | input: "g++", 39 | wantErr: false, 40 | }, 41 | { 42 | name: "package with architecture", 43 | input: "libc6:amd64", 44 | wantErr: false, 45 | }, 46 | { 47 | name: "package with repo", 48 | input: "ppa/package-name", 49 | wantErr: false, 50 | }, 51 | { 52 | name: "complex valid name", 53 | input: "lib32stdc++-9-dev:i386", 54 | wantErr: false, 55 | }, 56 | 57 | // Invalid package names - command injection attempts 58 | { 59 | name: "semicolon injection", 60 | input: "package; rm -rf /", 61 | wantErr: true, 62 | errMsg: "invalid package name", 63 | }, 64 | { 65 | name: "pipe injection", 66 | input: "package | cat /etc/passwd", 67 | wantErr: true, 68 | errMsg: "invalid package name", 69 | }, 70 | { 71 | name: "ampersand injection", 72 | input: "package && malicious-command", 73 | wantErr: true, 74 | errMsg: "invalid package name", 75 | }, 76 | { 77 | name: "backtick injection", 78 | input: "package`evil`", 79 | wantErr: true, 80 | errMsg: "invalid package name", 81 | }, 82 | { 83 | name: "dollar sign injection", 84 | input: "package$(bad)", 85 | wantErr: true, 86 | errMsg: "invalid package name", 87 | }, 88 | { 89 | name: "redirect injection", 90 | input: "package > /etc/passwd", 91 | wantErr: true, 92 | errMsg: "invalid package name", 93 | }, 94 | { 95 | name: "single quote injection", 96 | input: "package'; drop table users; --", 97 | wantErr: true, 98 | errMsg: "invalid package name", 99 | }, 100 | { 101 | name: "double quote injection", 102 | input: `package"; rm -rf /; "`, 103 | wantErr: true, 104 | errMsg: "invalid package name", 105 | }, 106 | { 107 | name: "backslash injection", 108 | input: `package\nmalicious`, 109 | wantErr: true, 110 | errMsg: "invalid package name", 111 | }, 112 | { 113 | name: "space injection", 114 | input: "package name with spaces", 115 | wantErr: true, 116 | errMsg: "invalid package name", 117 | }, 118 | { 119 | name: "tab injection", 120 | input: "package\tmalicious", 121 | wantErr: true, 122 | errMsg: "invalid package name", 123 | }, 124 | { 125 | name: "newline injection", 126 | input: "package\nmalicious", 127 | wantErr: true, 128 | errMsg: "invalid package name", 129 | }, 130 | { 131 | name: "null byte injection", 132 | input: "package\x00malicious", 133 | wantErr: true, 134 | errMsg: "invalid package name", 135 | }, 136 | { 137 | name: "parenthesis injection", 138 | input: "package(malicious)", 139 | wantErr: true, 140 | errMsg: "invalid package name", 141 | }, 142 | { 143 | name: "bracket injection", 144 | input: "package[malicious]", 145 | wantErr: true, 146 | errMsg: "invalid package name", 147 | }, 148 | { 149 | name: "curly brace injection", 150 | input: "package{malicious}", 151 | wantErr: true, 152 | errMsg: "invalid package name", 153 | }, 154 | { 155 | name: "asterisk injection", 156 | input: "package*", 157 | wantErr: true, 158 | errMsg: "invalid package name", 159 | }, 160 | { 161 | name: "question mark injection", 162 | input: "package?", 163 | wantErr: true, 164 | errMsg: "invalid package name", 165 | }, 166 | { 167 | name: "tilde injection", 168 | input: "~package", 169 | wantErr: true, 170 | errMsg: "invalid package name", 171 | }, 172 | 173 | // Edge cases 174 | { 175 | name: "empty string", 176 | input: "", 177 | wantErr: true, 178 | errMsg: "empty", 179 | }, 180 | { 181 | name: "very long name", 182 | input: string(make([]byte, 256)), 183 | wantErr: true, 184 | errMsg: "too long", 185 | }, 186 | } 187 | 188 | for _, tt := range tests { 189 | t.Run(tt.name, func(t *testing.T) { 190 | err := ValidatePackageName(tt.input) 191 | if (err != nil) != tt.wantErr { 192 | t.Errorf("ValidatePackageName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) 193 | return 194 | } 195 | if err != nil && tt.errMsg != "" { 196 | if !strings.Contains(err.Error(), tt.errMsg) { 197 | t.Errorf("ValidatePackageName(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg) 198 | } 199 | } 200 | }) 201 | } 202 | } 203 | 204 | func TestValidatePackageNames(t *testing.T) { 205 | tests := []struct { 206 | name string 207 | input []string 208 | wantErr bool 209 | }{ 210 | { 211 | name: "all valid names", 212 | input: []string{"vim", "git", "python3.8"}, 213 | wantErr: false, 214 | }, 215 | { 216 | name: "one invalid name", 217 | input: []string{"vim", "git; rm -rf /", "python3.8"}, 218 | wantErr: true, 219 | }, 220 | { 221 | name: "empty slice", 222 | input: []string{}, 223 | wantErr: false, 224 | }, 225 | { 226 | name: "empty string in slice", 227 | input: []string{"vim", "", "git"}, 228 | wantErr: true, 229 | }, 230 | } 231 | 232 | for _, tt := range tests { 233 | t.Run(tt.name, func(t *testing.T) { 234 | err := ValidatePackageNames(tt.input) 235 | if (err != nil) != tt.wantErr { 236 | t.Errorf("ValidatePackageNames(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr) 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /interface.go: -------------------------------------------------------------------------------- 1 | package syspkg 2 | 3 | import "github.com/bluet/syspkg/manager" 4 | 5 | // PackageManager is the interface that defines the methods for interacting with various package managers. 6 | type PackageManager interface { 7 | // IsAvailable checks if the package manager is available on the current system. 8 | IsAvailable() bool 9 | 10 | // GetPackageManager returns the name of the package manager. 11 | GetPackageManager() string 12 | 13 | // Install installs the specified packages using the package manager. 14 | // Returns PackageInfo for each successfully installed package with Status=installed. 15 | // Version and NewVersion fields will contain the installed version. 16 | Install(pkgs []string, opts *manager.Options) ([]manager.PackageInfo, error) 17 | 18 | // Delete removes the specified packages using the package manager. 19 | // Returns PackageInfo for each successfully removed package with Status=available. 20 | // Version field contains the removed version, NewVersion will be empty. 21 | Delete(pkgs []string, opts *manager.Options) ([]manager.PackageInfo, error) 22 | 23 | // Find searches for packages using the specified keywords and checks their installation status. 24 | // Cross-package manager status normalization ensures consistent behavior: 25 | // - Status=installed: Package is currently installed 26 | // - Status=available: Package exists in repositories but is not installed 27 | // (includes previously installed packages that have been removed, even with config files remaining) 28 | // - Status=upgradable: Package is installed but newer version is available 29 | // 30 | // Version field contains installed version (empty if not installed). 31 | // NewVersion field contains available version from repositories. 32 | // 33 | // Implementation notes: 34 | // - APT: Uses dpkg-query to check installation status 35 | // - YUM: Uses rpm -q to check installation status 36 | // - APT config-files state is normalized to available for cross-PM compatibility 37 | // - Both package managers provide accurate status detection 38 | Find(keywords []string, opts *manager.Options) ([]manager.PackageInfo, error) 39 | 40 | // ListInstalled lists all currently installed packages. 41 | // Returns packages with Status=installed, Version set to installed version, NewVersion empty. 42 | ListInstalled(opts *manager.Options) ([]manager.PackageInfo, error) 43 | 44 | // ListUpgradable lists all packages that have newer versions available. 45 | // Returns packages with Status=upgradable, Version=current, NewVersion=available. 46 | ListUpgradable(opts *manager.Options) ([]manager.PackageInfo, error) 47 | 48 | // Upgrade upgrades the specified packages to their latest versions. 49 | // Returns PackageInfo for each upgraded package with new version information. 50 | Upgrade(pkgs []string, opts *manager.Options) ([]manager.PackageInfo, error) 51 | 52 | // UpgradeAll upgrades all packages or only the specified ones. 53 | // Returns PackageInfo for each upgraded package with new version information. 54 | UpgradeAll(opts *manager.Options) ([]manager.PackageInfo, error) 55 | 56 | // Refresh refreshes the package index/repositories. 57 | // This should be called before search operations to ensure up-to-date package information. 58 | Refresh(opts *manager.Options) error 59 | 60 | // GetPackageInfo returns detailed information about the specified package. 61 | // Returns package metadata including name, version, architecture, and category. 62 | GetPackageInfo(pkg string, opts *manager.Options) (manager.PackageInfo, error) 63 | 64 | // Clean performs cleanup of package manager caches and temporary files. 65 | // The specific behavior depends on the package manager implementation. 66 | Clean(opts *manager.Options) error 67 | 68 | // AutoRemove removes packages that were automatically installed as dependencies 69 | // but are no longer needed by any manually installed packages. 70 | // Returns PackageInfo for each removed package with Status=available. 71 | AutoRemove(opts *manager.Options) ([]manager.PackageInfo, error) 72 | } 73 | 74 | // SysPkg is the interface that defines the methods for interacting with the SysPkg library. 75 | type SysPkg interface { 76 | // FindPackageManagers returns a map of available package managers based on the specified IncludeOptions. 77 | // If the AllAvailable option is set to true, all available package managers will be returned. 78 | // Otherwise, only the specified package managers will be returned. 79 | // If no suitable package managers are found, an error is returned. 80 | FindPackageManagers(include IncludeOptions) (map[string]PackageManager, error) 81 | 82 | // RefreshPackageManagers refreshes the internal package manager list based on the specified IncludeOptions, and returns the new list. 83 | // If the AllAvailable option is set to true, all available package managers will be included. 84 | // Otherwise, only the specified package managers will be included. 85 | // If no suitable package managers are found, an error is returned. 86 | RefreshPackageManagers(include IncludeOptions) (map[string]PackageManager, error) 87 | 88 | // GetPackageManager returns a PackageManager instance based on the specified name, from the list of available package managers specified in the IncludeOptions. 89 | // If the name is empty, the first available package manager will be returned. 90 | // If no suitable package manager is found, an error is returned. 91 | // Note: only package managers that are specified in the IncludeOptions when creating the SysPkg instance (with New() method) will be returned. If you want to use package managers that are not specified in the IncludeOptions, you should use the FindPackageManagers() method to get a list of all available package managers, or use RefreshPackageManagers() with the IncludeOptions parameter to refresh the package manager list. 92 | GetPackageManager(name string) (PackageManager, error) 93 | 94 | // Install(pkgs []string, opts *manager.Options) ([]manager.PackageInfo, error) 95 | // Delete(pkgs []string, opts *manager.Options) ([]manager.PackageInfo, error) 96 | // Find(keywords []string, opts *manager.Options) ([]manager.PackageInfo, error) 97 | // ListInstalled(opts *manager.Options) ([]manager.PackageInfo, error) 98 | // ListUpgradable(opts *manager.Options) ([]manager.PackageInfo, error) 99 | // Upgrade(opts *manager.Options) ([]manager.PackageInfo, error) 100 | // Refresh(opts *manager.Options) error 101 | // GetPackageInfo(pkg string, opts *manager.Options) (manager.PackageInfo, error) 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/multi-os-test.yml: -------------------------------------------------------------------------------- 1 | name: Multi-OS Package Manager Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | # Docker-based tests for different OS/package manager combinations 14 | docker-tests: 15 | name: Docker Tests (${{ matrix.os }}-${{ matrix.pm }}) 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | include: 21 | - os: ubuntu 22 | pm: apt 23 | dockerfile: ubuntu.Dockerfile 24 | test_tags: "unit,integration,apt" 25 | - os: rockylinux 26 | pm: yum 27 | dockerfile: rockylinux.Dockerfile 28 | test_tags: "unit,integration,yum" 29 | # TODO: Enable when DNF support is implemented 30 | # - os: fedora 31 | # pm: dnf 32 | # dockerfile: fedora.Dockerfile 33 | # test_tags: "unit,integration,dnf" 34 | # TODO: Enable when APK support is implemented 35 | # - os: alpine 36 | # pm: apk 37 | # dockerfile: alpine.Dockerfile 38 | # test_tags: "unit,integration,apk" 39 | 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: Build test container 48 | run: | 49 | docker build -f testing/docker/${{ matrix.dockerfile }} \ 50 | -t syspkg-test-${{ matrix.os }}:latest . 51 | 52 | - name: Run container tests 53 | run: | 54 | docker run --rm \ 55 | -v ${{ github.workspace }}:/workspace \ 56 | -e TEST_OS=${{ matrix.os }} \ 57 | -e TEST_PACKAGE_MANAGER=${{ matrix.pm }} \ 58 | -e IN_CONTAINER=true \ 59 | syspkg-test-${{ matrix.os }}:latest \ 60 | go test -v -tags="${{ matrix.test_tags }}" ./manager/${{ matrix.pm }} ./osinfo 61 | 62 | - name: Generate test fixtures 63 | run: | 64 | docker run --rm \ 65 | -v ${{ github.workspace }}:/workspace \ 66 | syspkg-test-${{ matrix.os }}:latest \ 67 | bash -c " 68 | mkdir -p testing/fixtures/${{ matrix.pm }} 69 | case '${{ matrix.pm }}' in 70 | apt) 71 | apt update 2>/dev/null 72 | apt search vim > testing/fixtures/apt/search-vim-${{ matrix.os }}.txt 2>/dev/null || true 73 | apt show vim > testing/fixtures/apt/show-vim-${{ matrix.os }}.txt 2>/dev/null || true 74 | ;; 75 | yum) 76 | yum search vim > testing/fixtures/yum/search-vim-${{ matrix.os }}.txt 2>/dev/null || true 77 | yum info vim > testing/fixtures/yum/info-vim-${{ matrix.os }}.txt 2>/dev/null || true 78 | ;; 79 | dnf) 80 | dnf search vim > testing/fixtures/dnf/search-vim-${{ matrix.os }}.txt 2>/dev/null || true 81 | dnf info vim > testing/fixtures/dnf/info-vim-${{ matrix.os }}.txt 2>/dev/null || true 82 | ;; 83 | apk) 84 | apk update 2>/dev/null 85 | apk search vim > testing/fixtures/apk/search-vim-${{ matrix.os }}.txt 2>/dev/null || true 86 | apk info vim > testing/fixtures/apk/info-vim-${{ matrix.os }}.txt 2>/dev/null || true 87 | ;; 88 | esac 89 | " 90 | 91 | - name: Upload test fixtures 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: test-fixtures-${{ matrix.os }}-${{ matrix.pm }} 95 | path: testing/fixtures/ 96 | retention-days: 30 97 | 98 | # Native runner tests for package managers requiring systemd/privileges 99 | native-tests: 100 | name: Native Tests (${{ matrix.os }}-${{ matrix.pm }}) 101 | runs-on: ${{ matrix.runner }} 102 | strategy: 103 | fail-fast: false 104 | matrix: 105 | include: 106 | - os: ubuntu 107 | runner: ubuntu-latest 108 | pm: apt 109 | setup: | 110 | sudo apt update 111 | sudo apt install -y flatpak 112 | - os: ubuntu 113 | runner: ubuntu-latest 114 | pm: snap 115 | setup: | 116 | sudo systemctl start snapd 117 | sudo snap wait system seed.loaded 118 | - os: ubuntu 119 | runner: ubuntu-latest 120 | pm: flatpak 121 | setup: | 122 | sudo apt update 123 | sudo apt install -y flatpak 124 | sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 125 | 126 | steps: 127 | - name: Checkout code 128 | uses: actions/checkout@v4 129 | 130 | - name: Set up Go 131 | uses: actions/setup-go@v5 132 | with: 133 | go-version: '1.23' 134 | cache: true 135 | 136 | - name: Setup package manager 137 | run: ${{ matrix.setup }} 138 | 139 | - name: Run integration tests 140 | run: | 141 | go test -v -tags="integration,system" ./manager/${{ matrix.pm }} 142 | 143 | - name: Run full system tests (if applicable) 144 | if: matrix.pm != 'snap' # Skip snap system tests to avoid conflicts 145 | run: | 146 | # Test basic operations that don't require actual installs 147 | go test -v -run="TestIsAvailable|TestList|TestSearch" ./manager/${{ matrix.pm }} 148 | 149 | # OS detection tests across different environments 150 | os-detection-tests: 151 | name: OS Detection Tests 152 | runs-on: ubuntu-latest 153 | steps: 154 | - name: Checkout code 155 | uses: actions/checkout@v4 156 | 157 | - name: Test OS detection in different containers 158 | run: | 159 | # Test Ubuntu detection 160 | docker run --rm -v $PWD:/workspace ubuntu:22.04 bash -c " 161 | apt-get update && apt-get install -y curl && 162 | cd /workspace && 163 | curl -L https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xz && 164 | /usr/local/go/bin/go test -v ./osinfo -run TestGetOSInfo 165 | " 166 | 167 | # Test Alpine detection 168 | docker run --rm -v $PWD:/workspace alpine:3.18 sh -c " 169 | cd /workspace && 170 | apk add --no-cache curl tar && 171 | curl -L https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xz && 172 | /usr/local/go/bin/go test -v ./osinfo -run TestGetOSInfo 173 | " 174 | 175 | # Summary job that depends on all tests 176 | test-summary: 177 | name: Test Summary 178 | runs-on: ubuntu-latest 179 | needs: [docker-tests, native-tests, os-detection-tests] 180 | if: always() 181 | steps: 182 | - name: Check test results 183 | run: | 184 | echo "Docker tests: ${{ needs.docker-tests.result }}" 185 | echo "Native tests: ${{ needs.native-tests.result }}" 186 | echo "OS detection tests: ${{ needs.os-detection-tests.result }}" 187 | 188 | if [[ "${{ needs.docker-tests.result }}" == "failure" || 189 | "${{ needs.native-tests.result }}" == "failure" || 190 | "${{ needs.os-detection-tests.result }}" == "failure" ]]; then 191 | echo "Some tests failed" 192 | exit 1 193 | fi 194 | echo "All tests passed!" 195 | -------------------------------------------------------------------------------- /manager/snap/utils.go: -------------------------------------------------------------------------------- 1 | package snap 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/bluet/syspkg/manager" 8 | ) 9 | 10 | // ParseInstallOutput parses the output of `snap install` command 11 | // and returns a list of PackageInfo 12 | // 13 | // Example output: 14 | // snap "deja-dup" is already installed, see 'snap help refresh' 15 | // blablaland-desktop (edge) 1.0.1 from AdeDev installed 16 | func ParseInstallOutput(msg string, opts *manager.Options) []manager.PackageInfo { 17 | var packages []manager.PackageInfo 18 | 19 | // remove the last empty line 20 | msg = strings.TrimSuffix(msg, "\n") 21 | var lines []string = strings.Split(string(msg), "\n") 22 | 23 | for _, line := range lines { 24 | if opts.Verbose { 25 | fmt.Printf("snap: %s", line) 26 | } 27 | if strings.HasPrefix(line, "snap \"") { 28 | parts := strings.Fields(line) 29 | name := strings.Trim(parts[1], "\"") 30 | // if name is empty, it might be not what we want 31 | if name == "" { 32 | continue 33 | } 34 | 35 | packageInfo := manager.PackageInfo{ 36 | Name: name, 37 | Status: manager.PackageStatusInstalled, 38 | PackageManager: pm, 39 | } 40 | packages = append(packages, packageInfo) 41 | } else if strings.HasSuffix(line, "installed") { 42 | parts := strings.Fields(line) 43 | name := parts[0] 44 | version := parts[2] 45 | // if name is empty, it might be not what we want 46 | if name == "" { 47 | continue 48 | } 49 | 50 | packageInfo := manager.PackageInfo{ 51 | Name: name, 52 | Version: version, 53 | Status: manager.PackageStatusInstalled, 54 | PackageManager: pm, 55 | } 56 | packages = append(packages, packageInfo) 57 | } 58 | } 59 | 60 | return packages 61 | } 62 | 63 | // ParseDeletedOutput parses the output of `snap search` command 64 | // and returns a list of PackageInfo 65 | // 66 | // Example output: 67 | // Name Version Publisher Notes Summary 68 | // blablaland-desktop 1.0.1 adedev - Blablaland Desktop 69 | func ParseSearchOutput(msg string, opts *manager.Options) []manager.PackageInfo { 70 | var packages []manager.PackageInfo 71 | 72 | // remove the last empty line 73 | msg = strings.TrimSuffix(msg, "\n") 74 | var lines []string = strings.Split(string(msg), "\n") 75 | 76 | // skip the first line 77 | for _, line := range lines[1:] { 78 | if opts.Verbose { 79 | fmt.Printf("%s: %s", pm, line) 80 | } 81 | parts := strings.Fields(line) 82 | if len(parts) < 5 { 83 | continue 84 | } 85 | 86 | // skip the first line (header/title) 87 | if parts[0] == "Name" { 88 | continue 89 | } 90 | 91 | packageInfo := manager.PackageInfo{ 92 | Name: parts[0], 93 | Version: parts[1], 94 | Status: manager.PackageStatusAvailable, 95 | PackageManager: pm, 96 | } 97 | packages = append(packages, packageInfo) 98 | } 99 | 100 | return packages 101 | 102 | } 103 | 104 | // cspell: disable 105 | // ParsePackageInfoOutput parses the output of `snap info` command 106 | // and returns a list of PackageInfo 107 | // 108 | // Example msg: 109 | // name: blablaland-desktop 110 | // summary: Blablaland Desktop 111 | // publisher: AdeDev 112 | // store-url: https://snapcraft.io/blablaland-desktop 113 | // license: unset 114 | // description: | 115 | // 116 | // Version bureau du jeu Blablaland (inclus Flash Player) 117 | // 118 | // snap-id: yEfmuhiQDVy5B2rxNLaPyUYOE6iJakwr 119 | // channels: 120 | // 121 | // latest/stable: – 122 | // latest/candidate: – 123 | // latest/beta: – 124 | // latest/edge: 1.0.1 2021-06-08 (3) 112MB - 125 | // 126 | // cspell: enable 127 | func ParsePackageInfoOutput(msg string, opts *manager.Options) manager.PackageInfo { 128 | var pkg manager.PackageInfo 129 | 130 | // remove the last empty line 131 | msg = strings.TrimSuffix(msg, "\n") 132 | lines := strings.Split(string(msg), "\n") 133 | 134 | for _, line := range lines { 135 | // remove all leading and trailing spaces 136 | line = strings.TrimSpace(line) 137 | if len(line) > 0 { 138 | parts := strings.SplitN(line, ":", 2) 139 | 140 | if len(parts) != 2 { 141 | continue 142 | } 143 | 144 | key := strings.TrimSpace(parts[0]) 145 | value := strings.TrimSpace(parts[1]) 146 | 147 | if key == "name" { 148 | pkg.Name = value 149 | } else if strings.HasPrefix(key, "latest/") { 150 | version := strings.Fields(value)[0] 151 | if pkg.Version == "" { 152 | pkg.Version = version 153 | } 154 | } 155 | } 156 | } 157 | 158 | pkg.PackageManager = "snap" 159 | 160 | return pkg 161 | } 162 | 163 | // ParseListUpgradableOutput parses the output of `snap refresh --list` command 164 | // and returns a list of PackageInfo 165 | // 166 | // Example msg: 167 | // bluet@ocisly:~/workspace/go-syspkg$ snap refresh --list 168 | // Name Version Rev Size Publisher Notes 169 | // firefox 112.0.1-1 2579 253MB mozilla✓ - 170 | // gnome-3-28-1804 3.28.0-19-g98f9e67.98f9e67 198 172MB canonical✓ - 171 | // bluet@ocisly:~/workspace/go-syspkg$ snap list|grep firefox 172 | // firefox 112.0-2 2559 latest/stable mozilla** - 173 | func ParseListUpgradableOutput(msg string, opts *manager.Options) []manager.PackageInfo { 174 | return ParseListOutput(msg, opts) 175 | } 176 | 177 | // ParseFindOutput parses the output of `snap search` command 178 | // and returns a list of PackageInfo 179 | // 180 | // Example output: 181 | // Name Version Publisher Notes Summary 182 | // blablaland-desktop 1.0.1 adedev - Blablaland Desktop 183 | func ParseFindOutput(msg string, opts *manager.Options) []manager.PackageInfo { 184 | return ParseListOutput(msg, opts) 185 | } 186 | 187 | // ParseListInstalledOutput parses the output of `snap list` command 188 | // and returns a list of PackageInfo 189 | // 190 | // Example output: 191 | // Name Version Rev Tracking Publisher Notes 192 | // bare 1.0 5 latest/stable canonical✓ base 193 | // blablaland-desktop 1.0.1 3 latest/edge adedev - 194 | // canonical-livepatch 10.5.3 196 latest/stable canonical✓ - 195 | // caprine 2.57.0 53 latest/stable sindresorhus - 196 | func ParseListInstalledOutput(msg string, opts *manager.Options) []manager.PackageInfo { 197 | return ParseListOutput(msg, opts) 198 | } 199 | 200 | func ParseListOutput(msg string, opts *manager.Options) []manager.PackageInfo { 201 | var packages []manager.PackageInfo 202 | 203 | // remove the last empty line 204 | msg = strings.TrimSuffix(msg, "\n") 205 | var lines []string = strings.Split(string(msg), "\n") 206 | 207 | for _, line := range lines { 208 | if opts.Verbose { 209 | fmt.Printf("%s: %s", pm, line) 210 | } 211 | parts := strings.Fields(line) 212 | if len(parts) < 5 { 213 | continue 214 | } 215 | 216 | // skip the first line (header/title) 217 | if parts[0] == "Name" { 218 | continue 219 | } 220 | 221 | packageInfo := manager.PackageInfo{ 222 | Name: parts[0], 223 | Version: parts[1], 224 | Status: manager.PackageStatusAvailable, 225 | PackageManager: pm, 226 | } 227 | packages = append(packages, packageInfo) 228 | } 229 | 230 | return packages 231 | } 232 | -------------------------------------------------------------------------------- /manager/command_runner.go: -------------------------------------------------------------------------------- 1 | // Package manager provides a package manager interface implementation 2 | package manager 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "os" 8 | "os/exec" 9 | "time" 10 | ) 11 | 12 | // CommandRunner provides an abstraction for executing system commands. 13 | // All non-interactive commands automatically get LC_ALL=C for consistent output. 14 | type CommandRunner interface { 15 | // Run executes a command with LC_ALL=C for consistent English output. 16 | // This is the primary method for simple non-interactive commands. 17 | Run(name string, args ...string) ([]byte, error) 18 | 19 | // RunContext executes with context support and LC_ALL=C, plus optional extra env. 20 | // Extra env vars are appended after LC_ALL=C, allowing override if needed. 21 | // Note: Later env values override earlier ones, so users can override LC_ALL=C 22 | // by passing their own LC_ALL value (e.g., "LC_ALL=zh_TW.UTF-8"). 23 | // For commands with no args but extra env, pass nil or []string{} for args. 24 | // Example: RunContext(ctx, "apt", []string{"update"}, "DEBIAN_FRONTEND=noninteractive") 25 | // Example: RunContext(ctx, "yum", []string{"info", "vim"}, "LC_ALL=zh_TW.UTF-8") // Overrides default LC_ALL=C 26 | RunContext(ctx context.Context, name string, args []string, env ...string) ([]byte, error) 27 | 28 | // RunInteractive executes in interactive mode with stdin/stdout/stderr passthrough. 29 | // Does NOT prepend LC_ALL=C (preserves user's locale for interaction). 30 | // Returns only error as output is written directly to provided streams. 31 | RunInteractive(ctx context.Context, name string, args []string, env ...string) error 32 | } 33 | 34 | // DefaultCommandRunner implements CommandRunner using real system commands 35 | type DefaultCommandRunner struct { 36 | Timeout time.Duration // Default timeout for commands 37 | } 38 | 39 | // NewDefaultCommandRunner creates a new DefaultCommandRunner with default timeout 40 | func NewDefaultCommandRunner() *DefaultCommandRunner { 41 | return &DefaultCommandRunner{ 42 | Timeout: 30 * time.Second, // Default 30 second timeout 43 | } 44 | } 45 | 46 | // Run executes a command with LC_ALL=C for consistent English output 47 | func (r *DefaultCommandRunner) Run(name string, args ...string) ([]byte, error) { 48 | ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) 49 | defer cancel() 50 | return r.RunContext(ctx, name, args) 51 | } 52 | 53 | // RunContext executes with context support and LC_ALL=C, plus optional extra env 54 | func (r *DefaultCommandRunner) RunContext(ctx context.Context, name string, args []string, env ...string) ([]byte, error) { 55 | cmd := exec.CommandContext(ctx, name, args...) 56 | 57 | // Prepend LC_ALL=C, then append any additional env vars 58 | // Note: Later values override earlier ones, so users can override LC_ALL=C if needed 59 | allEnv := append([]string{"LC_ALL=C"}, env...) 60 | cmd.Env = append(os.Environ(), allEnv...) 61 | 62 | return cmd.Output() 63 | } 64 | 65 | // RunInteractive executes in interactive mode with stdin/stdout/stderr passthrough 66 | func (r *DefaultCommandRunner) RunInteractive(ctx context.Context, name string, args []string, env ...string) error { 67 | cmd := exec.CommandContext(ctx, name, args...) 68 | 69 | // For interactive mode, preserve user's locale (no LC_ALL=C) 70 | if len(env) > 0 { 71 | cmd.Env = append(os.Environ(), env...) 72 | } 73 | 74 | // Connect stdin/stdout/stderr for interactive use 75 | cmd.Stdin = os.Stdin 76 | cmd.Stdout = os.Stdout 77 | cmd.Stderr = os.Stderr 78 | 79 | return cmd.Run() 80 | } 81 | 82 | // MockCommandRunner implements CommandRunner for testing 83 | type MockCommandRunner struct { 84 | // Commands maps "command args" to expected output 85 | Commands map[string][]byte 86 | // Errors maps "command args" to expected errors 87 | Errors map[string]error 88 | // InteractiveCalls tracks interactive command calls for verification 89 | InteractiveCalls []string 90 | // EnvCalls tracks environment variables passed to RunContext/RunInteractive 91 | EnvCalls map[string][]string 92 | } 93 | 94 | // NewMockCommandRunner creates a new MockCommandRunner for testing 95 | func NewMockCommandRunner() *MockCommandRunner { 96 | return &MockCommandRunner{ 97 | Commands: make(map[string][]byte), 98 | Errors: make(map[string]error), 99 | InteractiveCalls: []string{}, 100 | EnvCalls: make(map[string][]string), 101 | } 102 | } 103 | 104 | // Run returns mocked output for the given command with LC_ALL=C 105 | func (m *MockCommandRunner) Run(name string, args ...string) ([]byte, error) { 106 | return m.RunContext(context.Background(), name, args) 107 | } 108 | 109 | // RunContext returns mocked output for the given command 110 | func (m *MockCommandRunner) RunContext(ctx context.Context, name string, args []string, env ...string) ([]byte, error) { 111 | // Build command key for lookup 112 | cmdKey := m.buildKey(name, args) 113 | 114 | // Track environment variables for testing 115 | m.EnvCalls[cmdKey] = env 116 | 117 | // Check if we have a mocked error for this command 118 | if err, exists := m.Errors[cmdKey]; exists { 119 | return nil, err 120 | } 121 | 122 | // Return mocked output if available 123 | if output, exists := m.Commands[cmdKey]; exists { 124 | return output, nil 125 | } 126 | 127 | // Default: return error when no mock is found (catches missing mocks in tests) 128 | return nil, errors.New("no mock found for command: " + cmdKey) 129 | } 130 | 131 | // RunInteractive simulates interactive command execution 132 | func (m *MockCommandRunner) RunInteractive(ctx context.Context, name string, args []string, env ...string) error { 133 | // Track interactive calls for verification 134 | cmdKey := m.buildKey(name, args) 135 | m.InteractiveCalls = append(m.InteractiveCalls, cmdKey) 136 | 137 | // Track environment variables for testing 138 | m.EnvCalls[cmdKey] = env 139 | 140 | // Check if we have a mocked error for this command 141 | if err, exists := m.Errors[cmdKey]; exists { 142 | return err 143 | } 144 | 145 | return nil 146 | } 147 | 148 | // buildKey creates a consistent key for command lookup 149 | func (m *MockCommandRunner) buildKey(name string, args []string) string { 150 | cmdKey := name 151 | if len(args) > 0 { 152 | for _, arg := range args { 153 | cmdKey += " " + arg 154 | } 155 | } 156 | return cmdKey 157 | } 158 | 159 | // AddCommand adds a mocked command response 160 | func (m *MockCommandRunner) AddCommand(name string, args []string, output []byte, err error) { 161 | cmdKey := m.buildKey(name, args) 162 | m.Commands[cmdKey] = output 163 | if err != nil { 164 | m.Errors[cmdKey] = err 165 | } 166 | } 167 | 168 | // AddError adds a mocked command error (deprecated, use AddCommand with error) 169 | func (m *MockCommandRunner) AddError(name string, args []string, err error) { 170 | cmdKey := m.buildKey(name, args) 171 | m.Errors[cmdKey] = err 172 | } 173 | 174 | // AddCommandWithEnv adds a mocked command response with environment consideration 175 | // Note: In mock, we don't differentiate by env vars, but this method exists for API consistency 176 | func (m *MockCommandRunner) AddCommandWithEnv(name string, args []string, env []string, output []byte, err error) { 177 | m.AddCommand(name, args, output, err) 178 | } 179 | 180 | // WasInteractiveCalled checks if an interactive command was called 181 | func (m *MockCommandRunner) WasInteractiveCalled(name string, args []string) bool { 182 | cmdKey := m.buildKey(name, args) 183 | for _, call := range m.InteractiveCalls { 184 | if call == cmdKey { 185 | return true 186 | } 187 | } 188 | return false 189 | } 190 | 191 | // GetEnvForCommand returns the environment variables passed for a specific command 192 | func (m *MockCommandRunner) GetEnvForCommand(name string, args []string) []string { 193 | cmdKey := m.buildKey(name, args) 194 | return m.EnvCalls[cmdKey] 195 | } 196 | -------------------------------------------------------------------------------- /manager/yum/utils_test.go: -------------------------------------------------------------------------------- 1 | package yum 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/bluet/syspkg/manager" 9 | ) 10 | 11 | func TestCheckRpmInstallationStatus(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | packageNames []string 15 | mockedCommands map[string][]byte 16 | mockedErrors map[string]error 17 | expectedPackages map[string]manager.PackageInfo 18 | expectedError bool 19 | }{ 20 | { 21 | name: "single installed package", 22 | packageNames: []string{"vim-enhanced"}, 23 | mockedCommands: map[string][]byte{ 24 | "rpm --version": []byte("RPM version 4.14.3\n"), 25 | "rpm -q vim-enhanced": []byte("vim-enhanced-8.0.1763-19.el8_6.4.x86_64\n"), 26 | }, 27 | expectedPackages: map[string]manager.PackageInfo{ 28 | "vim-enhanced": { 29 | Name: "vim-enhanced", 30 | Version: "8.0.1763-19.el8_6.4", 31 | Status: manager.PackageStatusInstalled, 32 | PackageManager: "yum", 33 | }, 34 | }, 35 | expectedError: false, 36 | }, 37 | { 38 | name: "package not installed", 39 | packageNames: []string{"nonexistent"}, 40 | mockedCommands: map[string][]byte{ 41 | "rpm --version": []byte("RPM version 4.14.3\n"), 42 | }, 43 | mockedErrors: map[string]error{ 44 | "rpm -q nonexistent": errors.New("package nonexistent is not installed"), 45 | }, 46 | expectedPackages: map[string]manager.PackageInfo{}, 47 | expectedError: false, 48 | }, 49 | { 50 | name: "multiple packages mixed status", 51 | packageNames: []string{"vim-enhanced", "nonexistent", "bash"}, 52 | mockedCommands: map[string][]byte{ 53 | "rpm --version": []byte("RPM version 4.14.3\n"), 54 | "rpm -q vim-enhanced": []byte("vim-enhanced-8.0.1763-19.el8_6.4.x86_64\n"), 55 | "rpm -q bash": []byte("bash-4.4.20-1.el8.x86_64\n"), 56 | }, 57 | mockedErrors: map[string]error{ 58 | "rpm -q nonexistent": errors.New("package nonexistent is not installed"), 59 | }, 60 | expectedPackages: map[string]manager.PackageInfo{ 61 | "vim-enhanced": { 62 | Name: "vim-enhanced", 63 | Version: "8.0.1763-19.el8_6.4", 64 | Status: manager.PackageStatusInstalled, 65 | PackageManager: "yum", 66 | }, 67 | "bash": { 68 | Name: "bash", 69 | Version: "4.4.20-1.el8", 70 | Status: manager.PackageStatusInstalled, 71 | PackageManager: "yum", 72 | }, 73 | }, 74 | expectedError: false, 75 | }, 76 | { 77 | name: "rpm command not available", 78 | packageNames: []string{"vim-enhanced"}, 79 | mockedErrors: map[string]error{ 80 | "rpm --version": errors.New("rpm: command not found"), 81 | }, 82 | expectedPackages: nil, 83 | expectedError: true, 84 | }, 85 | { 86 | name: "empty package list", 87 | packageNames: []string{}, 88 | mockedCommands: map[string][]byte{}, 89 | expectedPackages: map[string]manager.PackageInfo{}, 90 | expectedError: false, 91 | }, 92 | } 93 | 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | // Create mock command runner 97 | runner := manager.NewMockCommandRunner() 98 | 99 | // Set up mocked commands and errors using the proper methods 100 | for cmd, output := range tt.mockedCommands { 101 | // Parse the command string to extract name and args 102 | parts := strings.Fields(cmd) 103 | if len(parts) > 0 { 104 | name := parts[0] 105 | args := parts[1:] 106 | runner.AddCommand(name, args, output, nil) 107 | } 108 | } 109 | for cmd, err := range tt.mockedErrors { 110 | // Parse the command string to extract name and args 111 | parts := strings.Fields(cmd) 112 | if len(parts) > 0 { 113 | name := parts[0] 114 | args := parts[1:] 115 | runner.AddCommand(name, args, nil, err) 116 | } 117 | } 118 | 119 | // Test the method using PackageManager 120 | pm := NewPackageManagerWithCustomRunner(runner) 121 | result, err := pm.checkRpmInstallationStatus(tt.packageNames) 122 | 123 | // Check error expectation 124 | if tt.expectedError && err == nil { 125 | t.Error("Expected error but got none") 126 | } 127 | if !tt.expectedError && err != nil { 128 | t.Errorf("Unexpected error: %v", err) 129 | } 130 | 131 | // Check results if no error expected 132 | if !tt.expectedError { 133 | if len(result) != len(tt.expectedPackages) { 134 | t.Errorf("Expected %d packages, got %d", len(tt.expectedPackages), len(result)) 135 | } 136 | 137 | for name, expectedPkg := range tt.expectedPackages { 138 | actualPkg, exists := result[name] 139 | if !exists { 140 | t.Errorf("Expected package %s not found in result", name) 141 | continue 142 | } 143 | 144 | if actualPkg.Name != expectedPkg.Name { 145 | t.Errorf("Package %s: expected name %s, got %s", name, expectedPkg.Name, actualPkg.Name) 146 | } 147 | if actualPkg.Version != expectedPkg.Version { 148 | t.Errorf("Package %s: expected version %s, got %s", name, expectedPkg.Version, actualPkg.Version) 149 | } 150 | if actualPkg.Status != expectedPkg.Status { 151 | t.Errorf("Package %s: expected status %s, got %s", name, expectedPkg.Status, actualPkg.Status) 152 | } 153 | if actualPkg.PackageManager != expectedPkg.PackageManager { 154 | t.Errorf("Package %s: expected package manager %s, got %s", name, expectedPkg.PackageManager, actualPkg.PackageManager) 155 | } 156 | } 157 | } 158 | }) 159 | } 160 | } 161 | 162 | func TestParseFindOutput_PureFunction(t *testing.T) { 163 | // Test that ParseFindOutput is now a pure function (no system calls) 164 | searchOutput := `Last metadata expiration check: 0:26:09 ago on Thu 22 May 2025 04:30:18 PM UTC. 165 | ==================================================Name Exactly Matched: vim ==================================================== 166 | vim-enhanced.x86_64 : A highly configurable, improved version of the vi text editor 167 | vim-common.x86_64 : Common files for vim 168 | ====================================================Name & Summary Matched: vim================================================== 169 | vim-filesystem.noarch : VIM filesystem layout 170 | vim-minimal.x86_64 : A minimal version of the VIM editor` 171 | 172 | // Create a PackageManager instance to test the method 173 | pm := NewPackageManager() 174 | packages := pm.ParseFindOutput(searchOutput, nil) 175 | 176 | expectedPackages := []string{"vim-enhanced", "vim-common", "vim-filesystem", "vim-minimal"} 177 | 178 | if len(packages) != len(expectedPackages) { 179 | t.Errorf("Expected %d packages, got %d", len(expectedPackages), len(packages)) 180 | } 181 | 182 | // All packages should have Status=Available by default (pure parsing) 183 | for i, pkg := range packages { 184 | if pkg.Status != manager.PackageStatusAvailable { 185 | t.Errorf("Package %d (%s): expected status %s, got %s", i, pkg.Name, manager.PackageStatusAvailable, pkg.Status) 186 | } 187 | if pkg.Version != "" { 188 | t.Errorf("Package %d (%s): expected empty version, got %s", i, pkg.Name, pkg.Version) 189 | } 190 | if pkg.PackageManager != "yum" { 191 | t.Errorf("Package %d (%s): expected package manager 'yum', got %s", i, pkg.Name, pkg.PackageManager) 192 | } 193 | } 194 | 195 | // Verify specific packages are found 196 | packageNames := make(map[string]bool) 197 | for _, pkg := range packages { 198 | packageNames[pkg.Name] = true 199 | } 200 | 201 | for _, expectedName := range expectedPackages { 202 | if !packageNames[expectedName] { 203 | t.Errorf("Expected package %s not found", expectedName) 204 | } 205 | } 206 | } 207 | 208 | func TestExtractVersionFromRpmOutput(t *testing.T) { 209 | tests := []struct { 210 | name string 211 | rpmOutput string 212 | packageName string 213 | expected string 214 | }{ 215 | { 216 | name: "standard package with epoch", 217 | rpmOutput: "vim-enhanced-2:8.0.1763-19.el8_6.4.x86_64", 218 | packageName: "vim-enhanced", 219 | expected: "2:8.0.1763-19.el8_6.4", 220 | }, 221 | { 222 | name: "package without epoch", 223 | rpmOutput: "bash-4.4.20-1.el8.x86_64", 224 | packageName: "bash", 225 | expected: "4.4.20-1.el8", 226 | }, 227 | { 228 | name: "package with complex name", 229 | rpmOutput: "python3-pip-9.0.3-22.el8.noarch", 230 | packageName: "python3-pip", 231 | expected: "9.0.3-22.el8", 232 | }, 233 | { 234 | name: "malformed output fallback", 235 | rpmOutput: "malformed-output", 236 | packageName: "package", 237 | expected: "malformed-output", 238 | }, 239 | } 240 | 241 | for _, tt := range tests { 242 | t.Run(tt.name, func(t *testing.T) { 243 | result := extractVersionFromRpmOutput(tt.rpmOutput, tt.packageName) 244 | if result != tt.expected { 245 | t.Errorf("Expected %q, got %q", tt.expected, result) 246 | } 247 | }) 248 | } 249 | } 250 | --------------------------------------------------------------------------------