Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

SHELL := /bin/bash
.PHONY: all iso iso-netinst iso-offline package sbom clean test help
.PHONY: all iso iso-netinst iso-offline package sbom clean test test-dep-resolver help

# Build configuration
CODENAME := trixie
Expand Down Expand Up @@ -37,6 +37,7 @@ help:
@echo " package PKG=x Build specific package (cx-core, cx-full, cx-archive-keyring)"
@echo " sbom Generate Software Bill of Materials"
@echo " test Run build verification tests"
@echo " test-dep-resolver Run dependency resolver unit tests"
@echo " clean Remove build artifacts"
@echo " deps Install build dependencies"
@echo ""
Expand Down Expand Up @@ -162,6 +163,10 @@ test:
./tests/verify-preseed.sh || true
@echo -e "$(GREEN)Tests complete$(NC)"

test-dep-resolver:
@echo -e "$(GREEN)Running dependency resolver tests...$(NC)"
./tests/dependency-resolver-tests.sh

# Clean build artifacts
clean:
@echo -e "$(YELLOW)Cleaning build artifacts...$(NC)"
Expand Down
276 changes: 276 additions & 0 deletions scripts/cx-dep-resolver.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
# CX Linux dependency conflict resolver
# SPDX-License-Identifier: BUSL-1.1

set -euo pipefail

APT_GET_BIN="${APT_GET_BIN:-apt-get}"
APT_CACHE_BIN="${APT_CACHE_BIN:-apt-cache}"
APT_MARK_BIN="${APT_MARK_BIN:-apt-mark}"
DPKG_QUERY_BIN="${DPKG_QUERY_BIN:-dpkg-query}"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

usage() {
cat <<'EOF'
CX Linux dependency conflict resolver

Usage:
scripts/cx-dep-resolver.sh [OPTIONS] PACKAGE...

Options:
--tree-depth N Dependency tree depth to print (default: 2)
--no-color Disable ANSI colors
-h, --help Show this help

Examples:
scripts/cx-dep-resolver.sh docker.io
scripts/cx-dep-resolver.sh --tree-depth 3 python3-pip nodejs

The resolver is read-only: it uses apt simulation and cache metadata. It does
not install, remove, or modify packages.
EOF
}

if [[ "${NO_COLOR:-}" == "1" ]]; then
RED=''
GREEN=''
YELLOW=''
BLUE=''
NC=''
fi

info() { printf "%b[INFO]%b %s\n" "$BLUE" "$NC" "$*"; }
ok() { printf "%b[OK]%b %s\n" "$GREEN" "$NC" "$*"; }
warn() { printf "%b[WARN]%b %s\n" "$YELLOW" "$NC" "$*"; }
bad() { printf "%b[RISK]%b %s\n" "$RED" "$NC" "$*"; }

require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
bad "Required command not found: $1"
exit 2
fi
}

normalize_alt() {
local value="$1"
value="${value%% (*}"
value="${value%%:*}"
value="${value//|/}"
value="${value//</}"
value="${value//>/}"
value="${value## }"
value="${value%% }"
printf "%s" "$value"
}

candidate_exists() {
local package="$1"
"$APT_CACHE_BIN" policy "$package" 2>/dev/null | awk '/Candidate:/ {print $2; found=1} END {exit !found}' | grep -vq "(none)"
}
Comment on lines +71 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of candidate_exists uses a pipeline with grep -vq. With set -o pipefail enabled, if grep exits early or if the package does not exist, the pipeline can return a non-zero exit status or trigger SIGPIPE issues. We can simplify this and make it more robust by performing the entire check within awk.

Suggested change
candidate_exists() {
local package="$1"
"$APT_CACHE_BIN" policy "$package" 2>/dev/null | awk '/Candidate:/ {print $2; found=1} END {exit !found}' | grep -vq "(none)"
}
candidate_exists() {
local package="$1"
"$APT_CACHE_BIN" policy "$package" 2>/dev/null | awk '/Candidate:/ { found = ($2 && $2 != "(none)"); exit } END { exit !found }'
}


print_dependency_tree() {
local package="$1"
local depth="$2"
local indent="${3:-}"
local seen="${4:-}"

printf "%s- %s\n" "$indent" "$package"

if (( depth <= 0 )); then
return 0
fi

if [[ ",$seen," == *",$package,"* ]]; then
printf "%s (cycle skipped)\n" "$indent"
return 0
fi

"$APT_CACHE_BIN" depends "$package" 2>/dev/null |
awk '/^[[:space:]]*(PreDepends|Depends):/ {print $2}' |
sed 's/[<>|]//g' |
awk 'NF && !seen[$0]++' |
while read -r dependency; do
print_dependency_tree "$dependency" "$((depth - 1))" "$indent " "${seen},${package}"
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Running recursive function calls inside a pipeline causes each level of recursion to execute in a nested subshell. This is highly inefficient and can lead to process exhaustion or unexpected behavior with variables. Additionally, with pipefail and set -e active, any failure in the pipeline can prematurely terminate the script. Reading the dependencies into an array using process substitution avoids these issues.

Suggested change
"$APT_CACHE_BIN" depends "$package" 2>/dev/null |
awk '/^[[:space:]]*(PreDepends|Depends):/ {print $2}' |
sed 's/[<>|]//g' |
awk 'NF && !seen[$0]++' |
while read -r dependency; do
print_dependency_tree "$dependency" "$((depth - 1))" "$indent " "${seen},${package}"
done
}
local dependencies=()
while read -r dependency; do
dependencies+=("$dependency")
done < <("$APT_CACHE_BIN" depends "$package" 2>/dev/null |
awk '/^[[:space:]]*(PreDepends|Depends):/ {print $2}' |
sed 's/[<>|]//g' |
awk 'NF && !seen[$0]++' || true)
for dependency in "${dependencies[@]}"; do
print_dependency_tree "$dependency" "$((depth - 1))" "$indent " "${seen},${package}"
done


print_plain_english_summary() {
local simulation="$1"
local removals="$2"
local held="$3"
local broken="$4"

if [[ -n "$broken" ]]; then
bad "APT cannot compute a clean install plan. Review the broken-package lines below before installing."
return 0
fi

if [[ -n "$removals" ]]; then
bad "APT would remove existing packages. This is a high-risk install plan."
return 0
fi

if [[ -n "$held" ]]; then
warn "APT reports held or changed held packages. Manual review is recommended."
return 0
fi

if grep -qE '^Inst ' <<<"$simulation"; then
ok "APT simulation produced an install plan without removals or broken-package errors."
else
ok "No package changes are required, or all requested packages are already installed."
fi
}

print_alternatives() {
local package="$1"
local alternatives

alternatives=$("$APT_CACHE_BIN" show "$package" 2>/dev/null |
awk -F': ' '/^Provides:/ {print $2}' |
tr ',' '\n' |
while read -r alt; do normalize_alt "$alt"; printf "\n"; done |
awk 'NF && !seen[$0]++' |
head -10)

if [[ -n "$alternatives" ]]; then
printf "Alternatives/provided names for %s:\n" "$package"
sed 's/^/ - /' <<<"$alternatives"
return 0
fi

local prefix="${package%%-*}"
if [[ "$prefix" != "$package" && -n "$prefix" ]]; then
alternatives=$("$APT_CACHE_BIN" search "^${prefix}" 2>/dev/null |
awk '{print $1}' |
grep -v "^${package}$" |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using grep -v "^${package}$" can fail or behave unexpectedly if the package name contains regex special characters (such as + in g++ or . in node.js). Using grep -Fvx performs a safe, literal, exact-line match instead.

Suggested change
grep -v "^${package}$" |
grep -Fvx "${package}" |

head -10 || true)
fi

if [[ -n "$alternatives" ]]; then
printf "Possible alternatives related to %s:\n" "$package"
sed 's/^/ - /' <<<"$alternatives"
else
printf "No obvious alternatives found for %s.\n" "$package"
fi
}

print_orphan_candidates() {
local auto_packages
auto_packages=$("$APT_MARK_BIN" showauto 2>/dev/null | head -30 || true)

if [[ -z "$auto_packages" ]]; then
printf "No automatically installed package candidates were reported.\n"
return 0
fi

printf "Automatically installed packages to review before cleanup:\n"
while read -r package; do
[[ -z "$package" ]] && continue
if "$DPKG_QUERY_BIN" -W -f='${db:Status-Abbrev}' "$package" 2>/dev/null | grep -q '^ii'; then
printf " - %s\n" "$package"
fi
done <<<"$auto_packages"
printf "Run 'sudo apt autoremove --dry-run' for the final orphan-removal plan.\n"
}

tree_depth=2
packages=()

while [[ $# -gt 0 ]]; do
case "$1" in
--tree-depth)
tree_depth="${2:-}"
shift 2
;;
Comment on lines +191 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If --tree-depth is passed as the last argument without a value, shift 2 will attempt to shift more arguments than are left. In Bash, this returns a non-zero exit status, which under set -e will cause the script to crash immediately. Adding a check to ensure the argument is present prevents this crash.

Suggested change
--tree-depth)
tree_depth="${2:-}"
shift 2
;;
--tree-depth)
if [[ $# -lt 2 ]]; then
bad "Option --tree-depth requires an argument"
exit 2
fi
tree_depth="$2"
shift 2
;;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
--no-color)
RED=''
GREEN=''
YELLOW=''
BLUE=''
NC=''
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
bad "Unknown option: $1"
usage
exit 2
;;
*)
packages+=("$1")
shift
;;
esac
done

if [[ $# -gt 0 ]]; then
packages+=("$@")
fi

if [[ ${#packages[@]} -eq 0 ]]; then
usage
exit 2
fi

if ! [[ "$tree_depth" =~ ^[0-9]+$ ]]; then
bad "--tree-depth must be a non-negative integer"
exit 2
fi

require_command "$APT_GET_BIN"
require_command "$APT_CACHE_BIN"
require_command "$APT_MARK_BIN"
require_command "$DPKG_QUERY_BIN"

info "Resolving dependency plan for: ${packages[*]}"

for package in "${packages[@]}"; do
if candidate_exists "$package"; then
ok "Candidate available for $package"
else
bad "No install candidate found for $package"
print_alternatives "$package"
exit 1
fi
done

simulation="$("$APT_GET_BIN" -s install "${packages[@]}" 2>&1 || true)"
removals="$(grep -E '^(Remv|The following packages will be REMOVED:)' <<<"$simulation" || true)"
held="$(grep -Ei 'held|kept back|changed held' <<<"$simulation" || true)"
broken="$(grep -Ei 'broken packages|unmet dependencies|conflicts with|but it is not going to be installed' <<<"$simulation" || true)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If apt-get fails with a fatal error (such as E: ...), the script might exit with a success status (0) if the error message does not match the current broken regex. Including ^E: in the regex ensures that any standard APT error is correctly caught and treated as a failure.

Suggested change
broken="$(grep -Ei 'broken packages|unmet dependencies|conflicts with|but it is not going to be installed' <<<"$simulation" || true)"
broken="$(grep -Ei 'broken packages|unmet dependencies|conflicts with|but it is not going to be installed|^E:' <<<"$simulation" || true)"


printf "\nDependency tree:\n"
for package in "${packages[@]}"; do
print_dependency_tree "$package" "$tree_depth"
done

printf "\nAPT simulation summary:\n"
grep -E '^(Inst|Remv|Conf|The following|[0-9]+ upgraded|E:|N:)' <<<"$simulation" || printf "%s\n" "$simulation"

printf "\nPlain-English risk assessment:\n"
print_plain_english_summary "$simulation" "$removals" "$held" "$broken"

printf "\nAlternative package hints:\n"
for package in "${packages[@]}"; do
print_alternatives "$package"
done

printf "\nOrphan cleanup review:\n"
print_orphan_candidates

if [[ -n "$broken" || -n "$removals" ]]; then
exit 1
fi
Loading