#!/bin/sh

# Copyright (C) 2026 Devscripts Maintainers
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <https://www.gnu.org/licenses/>.

# Test suite for dcmd.
#
# Besides the regular expansion and filtering behaviour this covers the
# command injection reported in <https://bugs.debian.org/1139271>, where a
# crafted 'Files' entry in a .dsc/.changes file was passed to eval(1) by
# dcmd and executed the embedded shell code ('dcmd echo' on a file
# containing `"... $(id)"' ran id).

set -u

WORKDIR="$(readlink -f "${0%/*}")"

if test "${1:-}" = --installed; then
    COMMAND=dcmd
    shift
else
    COMMAND="$WORKDIR/../scripts/dcmd.sh"
fi

# Make the 'sort' and 'tac' output deterministic.
LC_ALL=C
export LC_ALL

# Run dcmd from within the fixture directory, so that the file names it
# prints are relative and easy to compare.
run_dcmd() {
    ( cd "$FIXTUREDIR" && $COMMAND "$@" )
}

# Run dcmd, capturing merged stdout+stderr in OUT and the exit status in RC.
capture() {
    OUT="$(run_dcmd "$@" 2>&1)"
    RC=$?
}

setUp() {
    FIXTUREDIR="$(mktemp -d -p "$SHUNIT_TMPDIR" dcmd.XXXXXX)"
}

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

write_changes() {
    cat > "$FIXTUREDIR/foo_1.0-1_amd64.changes" <<'EOF'
Format: 1.8
Date: Sat, 07 Nov 2026 00:00:00 +0000
Source: foo
Binary: foo foo-doc
Architecture: amd64
Version: 1.0-1
Distribution: unstable
Urgency: low
Maintainer: Test <test@example.com>
Description: test package
 foo - test
 foo-doc - documentation
Changes:
 foo (1.0-1) unstable; urgency=low
 .
   * test
Files:
 11111111111111111111111111111111 100 devel optional foo_1.0-1.dsc
 22222222222222222222222222222222 100 devel optional foo_1.0.orig.tar.gz
 33333333333333333333333333333333 100 devel optional foo_1.0-1.diff.gz
 44444444444444444444444444444444 100 admin optional foo_1.0-1_amd64.deb
 55555555555555555555555555555555 100 doc optional foo-doc_1.0-1_all.deb
 66666666666666666666666666666666 100 devel optional foo_1.0-1.buildinfo
 77777777777777777777777777777777 100 debian-installer optional foo-udeb_1.0-1_amd64.udeb
 88888888888888888888888888888888 100 debian-installer optional foo-di_1.0-1_all.udeb
EOF
}

write_dsc() {
    cat > "$FIXTUREDIR/foo_1.0-1.dsc" <<'EOF'
Format: 1.8
Source: foo
Binary: foo
Architecture: any
Version: 1.0-1
Maintainer: Test <test@example.com>
Standards-Version: 4.0.0
Build-Depends: debhelper-compat (= 13)
Package-List:
 foo deb devel optional arch=any
Files:
 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 100 foo_1.0.orig.tar.gz
 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 100 foo_1.0-1.diff.gz
EOF
}

# The malicious file from the bug report: the last line must not be
# interpreted as a file name, let alone be evaluated by the shell.
write_poc_changes() {
    cat > "$FIXTUREDIR/poc.changes" <<'EOF'
Format: 1.8
Files:
 ffffffffffffffffffffffffffffffff 1337 abc optional "$(id)"
EOF
}

# ---------------------------------------------------------------------------
# Regression tests for bug #1139271 (arbitrary code execution)
# ---------------------------------------------------------------------------

testPocDoesNotExecute() {
    write_poc_changes
    capture echo poc.changes
    assertEquals "dcmd echo poc.changes exits 0" 0 "$RC"
    assertEquals "only the .changes file itself is printed" \
        "poc.changes" "$OUT"
    case $OUT in
        *"uid="*) fail "command substitution was executed: $OUT" ;;
    esac
}

testCommandSubstitutionWithSideEffectNotExecuted() {
    cat > "$FIXTUREDIR/evil.changes" <<'EOF'
Format: 1.8
Files:
 ffffffffffffffffffffffffffffffff 1337 abc optional $(id>pwned)
EOF
    capture echo evil.changes
    assertEquals "dcmd echo evil.changes exits 0" 0 "$RC"
    assertFalse "command substitution must not be executed" \
        "[ -e '$FIXTUREDIR/pwned' ]"
    assertEquals "crafted entry is not expanded" "evil.changes" "$OUT"
}

testBackticksWithSideEffectNotExecuted() {
    cat > "$FIXTUREDIR/evil.changes" <<'EOF'
Format: 1.8
Files:
 ffffffffffffffffffffffffffffffff 1337 abc optional `id>pwned`
EOF
    capture echo evil.changes
    assertEquals "dcmd echo evil.changes exits 0" 0 "$RC"
    assertFalse "backticks must not be executed" \
        "[ -e '$FIXTUREDIR/pwned' ]"
}

testShellMetacharactersNotExecuted() {
    cat > "$FIXTUREDIR/evil.changes" <<'EOF'
Format: 1.8
Files:
 ffffffffffffffffffffffffffffffff 1337 abc optional x;id>pwned
 ffffffffffffffffffffffffffffffff 1337 abc optional a$(id>pwned)b
 ffffffffffffffffffffffffffffffff 1337 abc optional foo"bar
EOF
    capture echo evil.changes
    assertEquals "dcmd echo evil.changes exits 0" 0 "$RC"
    assertFalse "shell metacharacters must not be executed" \
        "[ -e '$FIXTUREDIR/pwned' ]"
    assertEquals "no crafted entry is expanded" "evil.changes" "$OUT"
}

# ---------------------------------------------------------------------------
# Expansion
# ---------------------------------------------------------------------------

testExpandChanges() {
    write_changes
    capture foo_1.0-1_amd64.changes
    assertEquals "dcmd <changes> exits 0" 0 "$RC"
    assertEquals "expanded file list" \
"foo_1.0-1.dsc
foo_1.0.orig.tar.gz
foo_1.0-1.diff.gz
foo_1.0-1_amd64.deb
foo-doc_1.0-1_all.deb
foo_1.0-1.buildinfo
foo-udeb_1.0-1_amd64.udeb
foo-di_1.0-1_all.udeb
foo_1.0-1_amd64.changes" "$OUT"
}

testExpandWithCommand() {
    write_changes
    capture echo foo_1.0-1_amd64.changes
    assertEquals "dcmd echo <changes> exits 0" 0 "$RC"
    assertEquals "command receives the expanded list" \
"foo_1.0-1.dsc foo_1.0.orig.tar.gz foo_1.0-1.diff.gz foo_1.0-1_amd64.deb foo-doc_1.0-1_all.deb foo_1.0-1.buildinfo foo-udeb_1.0-1_amd64.udeb foo-di_1.0-1_all.udeb foo_1.0-1_amd64.changes" "$OUT"
}

testExpandDsc() {
    write_dsc
    capture foo_1.0-1.dsc
    assertEquals "dcmd <dsc> exits 0" 0 "$RC"
    assertEquals "expanded .dsc file list" \
"foo_1.0.orig.tar.gz
foo_1.0-1.diff.gz
foo_1.0-1.dsc" "$OUT"
}

# Filenames containing '~' (used for pre-releases, backports, ...) must be
# expanded as well.
testExpandTildeFilenames() {
    cat > "$FIXTUREDIR/tilde.changes" <<'EOF'
Format: 1.8
Files:
 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 100 foo_1.0~rc1.orig.tar.gz
 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 100 foo_1.0~rc1-1_amd64.deb
EOF
    capture tilde.changes
    assertEquals "dcmd tilde.changes exits 0" 0 "$RC"
    assertEquals "filenames with '~' are expanded" \
"foo_1.0~rc1.orig.tar.gz
foo_1.0~rc1-1_amd64.deb
tilde.changes" "$OUT"
}

# A .changes file may itself be referenced from another .changes file.
testExpandChangesListedInFiles() {
    cat > "$FIXTUREDIR/parent.changes" <<'EOF'
Format: 1.8
Files:
 ffffffffffffffffffffffffffffffff 100 devel optional child_1.0-1_amd64.changes
EOF
    capture parent.changes
    assertEquals "dcmd parent.changes exits 0" 0 "$RC"
    assertEquals "nested .changes file is expanded" \
"child_1.0-1_amd64.changes
parent.changes" "$OUT"
}

# ---------------------------------------------------------------------------
# Output modifiers
# ---------------------------------------------------------------------------

testPackageName() {
    write_changes
    capture -p foo_1.0-1_amd64.changes
    assertEquals "dcmd -p exits 0" 0 "$RC"
    assertEquals "package name part only" \
"foo
foo
foo
foo
foo-doc
foo
foo-udeb
foo-di
foo" "$OUT"
}

testSort() {
    write_changes
    capture -s foo_1.0-1_amd64.changes
    assertEquals "dcmd -s exits 0" 0 "$RC"
    assertEquals "sorted output" \
"foo-di_1.0-1_all.udeb
foo-doc_1.0-1_all.deb
foo-udeb_1.0-1_amd64.udeb
foo_1.0-1.buildinfo
foo_1.0-1.diff.gz
foo_1.0-1.dsc
foo_1.0-1_amd64.changes
foo_1.0-1_amd64.deb
foo_1.0.orig.tar.gz" "$OUT"
}

testTac() {
    write_changes
    capture -t foo_1.0-1_amd64.changes
    assertEquals "dcmd -t exits 0" 0 "$RC"
    assertEquals "reversed output" \
"foo_1.0-1_amd64.changes
foo-di_1.0-1_all.udeb
foo-udeb_1.0-1_amd64.udeb
foo_1.0-1.buildinfo
foo-doc_1.0-1_all.deb
foo_1.0-1_amd64.deb
foo_1.0-1.diff.gz
foo_1.0.orig.tar.gz
foo_1.0-1.dsc" "$OUT"
}

# ---------------------------------------------------------------------------
# Filtering
# ---------------------------------------------------------------------------

assert_filtered() {
    local expected="$1"
    shift
    capture "$@"
    assertEquals "dcmd $* exits 0" 0 "$RC"
    assertEquals "dcmd $* output" "$expected" "$OUT"
}

testSelectDsc() {
    write_changes
    assert_filtered "foo_1.0-1.dsc" --dsc foo_1.0-1_amd64.changes
}

testSelectDeb() {
    write_changes
    assert_filtered "foo_1.0-1_amd64.deb
foo-doc_1.0-1_all.deb" --deb foo_1.0-1_amd64.changes
}

testSelectArchdeb() {
    write_changes
    assert_filtered "foo_1.0-1_amd64.deb" --archdeb foo_1.0-1_amd64.changes
}

testSelectIndepdeb() {
    write_changes
    assert_filtered "foo-doc_1.0-1_all.deb" --indepdeb foo_1.0-1_amd64.changes
}

testSelectUdeb() {
    write_changes
    assert_filtered "foo-udeb_1.0-1_amd64.udeb
foo-di_1.0-1_all.udeb" --udeb foo_1.0-1_amd64.changes
}

testSelectArchudeb() {
    write_changes
    assert_filtered "foo-udeb_1.0-1_amd64.udeb" --archudeb foo_1.0-1_amd64.changes
}

testSelectIndepudeb() {
    write_changes
    assert_filtered "foo-di_1.0-1_all.udeb" --indepudeb foo_1.0-1_amd64.changes
}

testSelectTar() {
    write_changes
    assert_filtered "foo_1.0.orig.tar.gz" --tar foo_1.0-1_amd64.changes
}

testSelectOrigAlias() {
    write_changes
    assert_filtered "foo_1.0.orig.tar.gz" --orig foo_1.0-1_amd64.changes
}

testSelectDiff() {
    write_changes
    assert_filtered "foo_1.0-1.diff.gz" --diff foo_1.0-1_amd64.changes
}

testSelectDebtarAlias() {
    write_changes
    assert_filtered "foo_1.0-1.diff.gz" --debtar foo_1.0-1_amd64.changes
}

testSelectBuildinfo() {
    write_changes
    assert_filtered "foo_1.0-1.buildinfo" --buildinfo foo_1.0-1_amd64.changes
}

testSelectBchanges() {
    write_changes
    assert_filtered "foo_1.0-1_amd64.changes" --bchanges foo_1.0-1_amd64.changes
}

testNoDiff() {
    write_changes
    capture --no-diff foo_1.0-1_amd64.changes
    assertEquals "dcmd --no-diff exits 0" 0 "$RC"
    assertEquals "diff is excluded" \
"foo_1.0-1.dsc
foo_1.0.orig.tar.gz
foo_1.0-1_amd64.deb
foo-doc_1.0-1_all.deb
foo_1.0-1.buildinfo
foo-udeb_1.0-1_amd64.udeb
foo-di_1.0-1_all.udeb
foo_1.0-1_amd64.changes" "$OUT"
}

testNoFilterKeepsOnlyChanges() {
    write_changes
    capture --no-tar --no-diff --no-deb --no-udeb --no-buildinfo --no-dsc \
        foo_1.0-1_amd64.changes
    assertEquals "dcmd with all --no-* exits 0" 0 "$RC"
    assertEquals "only the .changes file remains" \
        "foo_1.0-1_amd64.changes" "$OUT"
}

# ---------------------------------------------------------------------------
# Missing / conflicting selections
# ---------------------------------------------------------------------------

testMissingRequestedFileFails() {
    cat > "$FIXTUREDIR/nodeb.changes" <<'EOF'
Format: 1.8
Files:
 44444444444444444444444444444444 100 admin optional foo_1.0-1_amd64.deb
EOF
    capture --indepdeb nodeb.changes
    assertEquals "missing arch-indep package is an error" 1 "$RC"
    assertEquals "error message" \
        "nodeb.changes: arch-indep packages not found" "$OUT"
}

testNoFailOnMissing() {
    cat > "$FIXTUREDIR/nodeb.changes" <<'EOF'
Format: 1.8
Files:
 44444444444444444444444444444444 100 admin optional foo_1.0-1_amd64.deb
EOF
    capture --no-fail-on-missing --indepdeb nodeb.changes
    assertEquals "dcmd --no-fail-on-missing exits 0" 0 "$RC"
    assertEquals "no output when nothing matches" "" "$OUT"
}

testNoFailOnMissingShortOption() {
    cat > "$FIXTUREDIR/nodeb.changes" <<'EOF'
Format: 1.8
Files:
 44444444444444444444444444444444 100 admin optional foo_1.0-1_amd64.deb
EOF
    capture -r --indepdeb nodeb.changes
    assertEquals "dcmd -r exits 0" 0 "$RC"
    assertEquals "no output when nothing matches" "" "$OUT"
}

testSourceChangesNotFound() {
    write_changes
    capture --schanges foo_1.0-1_amd64.changes
    assertEquals "binary .changes is not a source .changes" 1 "$RC"
    assertEquals "error message" \
        "foo_1.0-1_amd64.changes: source .changes file not found" "$OUT"
}

testCombiningPositiveAndNegativeFiltersFails() {
    write_changes
    capture --dsc --no-diff foo_1.0-1_amd64.changes
    assertEquals "combining --foo and --no-foo is an error" 1 "$RC"
    assertEquals "error message" \
        "Can't combine --foo and --no-foo options" "${OUT#*: }"
}

testUnknownOptionFails() {
    capture --does-not-exist foo_1.0-1_amd64.changes
    assertEquals "unknown option is an error" 1 "$RC"
    assertEquals "error message" \
        "Unknown option '--does-not-exist'" "${OUT#*: }"
}

# ---------------------------------------------------------------------------

. shunit2
