#!/bin/bash # SPDX-License-Identifier: GPL-2.0-only printUsage() { cat << EOF opkg-feed action [--conf-dir=PATH] [-h|--help] [action-options] ACTIONS list List software feeds as they appear in the configuration file(s). add Add a new software feed. Feeds are enabled and not trusted by default. modify Modify an existing software feed. Values not modified will not be changed. remove Remove an existing software feed. ACTION OPTIONS list option(s): [--key-value] add option(s): --uri=LOCATION [--clobber] [--enabled=ENABLED] [--source-type=TYPE] [--trusted=TRUSTED] modify option(s): [--clobber] [--enabled=ENABLED] [--name=NAME] [--source-type=TYPE] [--trusted=TRUSTED] [--uri=LOCATION] OPTIONS --clobber Allow an existing feed to be overwritten. --conf-dir=PATH PATH to the opkg configuration directory. PATH may be relative. Symlinks will be followed. Default: "/etc/opkg" --enabled=ENABLED Enabled state of the feed. Disabled feeds remain on the system but are ignored by opkg. ENABLED may be "1" or "0". Default: "1" -h|--help Display help information and exit. --key-value Print feed information in the "key=value" style. Feeds are separated by empty lines. --name=NAME Name of the feed. Will not overwrite an existing feed unless --clobber is specified. --source-type=TYPE Refers to how the repository list is stored. TYPE may be "src", "src/gz", "dist", or "dist/gz". Default: "src/gz" --trusted=TRUSTED Trusted state of the feed. Trusted feeds will not be cryptographically verified by opkg to be a safe and secure source of packages. A system administrator may have reason to trust the feed regardless. TRUSTED may be "1" or "0". Default: "0" --uri=LOCATION LOCATION of the feed, such as "https://..." or "file://...". EXIT STATUS 0 No error; command ran to completion 3 Invalid argument 4 The named feed was not found 5 The named feed already exists EOF } # ------------------------------------------------------------------------------ # Globals # ------------------------------------------------------------------------------ declare -r S_OK=0 declare -r E_GENERIC_ERROR=1 declare -r E_INVALID_ARG=3 declare -r E_FEED_NOT_FOUND=4 declare -r E_FEED_ALREADY_EXISTS=5 declare -r IE_GREP_MATCH=0 declare -r IE_GREP_NO_MATCH=1 declare -r SPACE_PATTERN='\s' declare -r DOUBLE_QUOTE_PATTERN='"' declare -A ARGS declare -A DECOMP_FEED # ------------------------------------------------------------------------------ # Utility functions # ------------------------------------------------------------------------------ allEmpty() { for arg in "$@" ; do if [ -n "$arg" ] ; then return $E_GENERIC_ERROR fi done return $S_OK } escapeChars() { local string=$1 local chars=$2 for (( index=0 ; index<${#1} ; index++ )) ; do char="${string:$index:1}" case $char in [$chars]) echo -n "\\$char" ;; *) echo -n "$char" ;; esac done } escapeRegex() { # Escape the \ here for the consumer echo "$( escapeChars "$1" '[\\^$.|?*+(){}' )" } parseBool() { if [[ "$1" =~ ^[10]$ ]] ; then echo "$1" fi } # ------------------------------------------------------------------------------ # Action helpers # ------------------------------------------------------------------------------ createFeedLineRegex() { local feedName="$( escapeRegex "$1" )" # The regex constructed here was derived from the regex in opkglib's opkg_conf.c at # https://urldefense.com/v3/__https://git.yoctoproject.org/cgit/cgit.cgi/opkg/tree/libopkg/opkg_conf.c__;!fqWJcnlTkjM!9i4Onh9iVJK9m0kcWmZI6WKisWsX5nNe__ZqOSr7If-Hh6WGsOE9HNr9-6dNcgwHL6GfEg$ # Disabled marker enabledPattern='^#?\s*'; # Source type capture groups (1, 2, 3) # 1 = full source type with any quotes # 2 = source type without quotes if quoted # 3 = source type if unquoted sourceTypePattern='("([^"]*)"|(\S+))\s+'; # Feed name capture groups (4, 5, 6) # 4 = full feed name with any quotes # 5 = feed name without quotes if quoted # 6 = feed name if unquoted namePattern='("([^"]*)"|(\S*))\s+'; if [ -n "$feedName" ] ; then namePattern="(\"$feedName\"|$feedName)\\s+"; fi # Feed URI capture groups (7, 8, 9) # 7 = full URI with any quotes # 8 = URI without quotes if quoted # 9 = URI if unquoted uriPattern='("([^"]*)"|(\S*))\s*'; # Feed option capture groups (10, 11) # 10 = options with brackets # 11 = options without brackets optionsPattern='(\[([^]]+)\])?\s*'; # Feed "extra" capture group (12) # 12 = unused extra field extraPattern='(\S+)?\s*'; # Trailing garbage capture group (13) # 13 = if non-empty, opkg will complain about and ignore the feed entry garbagePattern='(.*)?\s*'; echo "$enabledPattern$sourceTypePattern$namePattern$uriPattern$optionsPattern$extraPattern$garbagePattern" } decomposeFeedLine() { local feedLine="$1" local feedRegex=$( createFeedLineRegex ) for key in "${!DECOMP_FEED[@]}" ; do unset DECOMP_FEED["$key"] done if ! [[ "$feedLine" =~ $feedRegex ]] ; then return $E_GENERIC_ERROR fi # Ignore feed lines with trailing garbage if [ -n "${BASH_REMATCH[13]}" ] ; then return $E_GENERIC_ERROR fi DECOMP_FEED[enabled]='1' if [[ "${feedLine::1}" == '#' ]] ; then DECOMP_FEED[enabled]='0' fi local sourceTypeQuoted="${BASH_REMATCH[2]}" local sourceTypeUnquoted="${BASH_REMATCH[3]}" DECOMP_FEED[source-type]="${sourceTypeQuoted:-$sourceTypeUnquoted}" local nameQuoted="${BASH_REMATCH[5]}" local nameUnquoted="${BASH_REMATCH[6]}" DECOMP_FEED[name]="${nameQuoted:-$nameUnquoted}" local uriQuoted="${BASH_REMATCH[8]}" local uriUnquoted="${BASH_REMATCH[9]}" DECOMP_FEED[uri]="${uriQuoted:-$uriUnquoted}" local options="${BASH_REMATCH[11]}" for option in $options ; do if [[ "$option" == 'trusted=yes' ]] ; then DECOMP_FEED[trusted]='1' fi done DECOMP_FEED[extra]="${BASH_REMATCH[12]}" local sourceTypeRegex='^(src|dist)(/gz)?$' if [[ "${DECOMP_FEED[source-type]}" =~ $sourceTypeRegex ]] ; then return $S_OK fi return $E_GENERIC_ERROR } formatFeedLine() { local enabled="$1" local sourceType="$2" local name="$3" local uri="$4" local trusted="$5" local extra="$6" if [[ "$name" =~ $SPACE_PATTERN ]] ; then name="\"$name\"" fi if [[ "$uri" =~ $SPACE_PATTERN ]] ; then uri="\"$uri\"" fi local options= if [[ "$trusted" == '1' ]] ; then options='[trusted=yes]' fi if [[ "$enabled" == '0' ]] ; then echo -n '# ' fi echo -n "$sourceType $name $uri" if [ -n "$options" ] ; then echo -n " $options" fi if [ -n "$extra" ] ; then echo -n " $extra" fi } feedExists() { if [ -z "$1" ] ; then return $E_GENERIC_ERROR fi local feedRegex="$( createFeedLineRegex "$1" )" grep --perl-regexp --only-matching --no-filename "$feedRegex" "${ARGS[confDir]}"/*.conf &>/dev/null if [[ "$?" == "$IE_GREP_MATCH" ]] ; then return $S_OK fi return $E_GENERIC_ERROR } # ------------------------------------------------------------------------------ # Actions # ------------------------------------------------------------------------------ listFeeds() { for filePath in "${ARGS[confDir]}"/*.conf ; do if [ -f "$filePath" ] ; then while IFS='' read -r line || [ -n "$line" ] ; do if decomposeFeedLine "$line" ; then if [[ "${ARGS[kv]}" == '1' ]] ; then for key in "${!DECOMP_FEED[@]}" ; do local value=${DECOMP_FEED["$key"]} if [ -n "$value" ] ; then echo "$key=$value" fi done echo '' else echo "$line" fi fi done < "$filePath" fi done } addFeed() { if [ -z "${ARGS[name]}" ] || [ -z "${ARGS[uri]}" ] ; then return $E_INVALID_ARG fi if feedExists "${ARGS[name]}" ; then if [[ "${ARGS[clobber]}" != '1' ]] ; then return $E_FEED_ALREADY_EXISTS else modifyFeed return $? fi fi local fileName=$( echo "${ARGS[name]}" | sed 's/[^a-zA-Z0-9_=+-]//g' ) local feedLine=$( formatFeedLine \ "${ARGS[enabled]:-1}" \ "${ARGS[source-type]:-src/gz}" \ "${ARGS[name]}" \ "${ARGS[uri]}" \ "${ARGS[trusted]:-0}" \ '' ) printf '%s\n' "$feedLine" >> "${ARGS[confDir]}/${fileName:-misc-feeds}.conf" return $S_OK } modifyFeed() { if [ -z "${ARGS[name]}" ] ; then return $E_INVALID_ARG fi if ! feedExists "${ARGS[name]}" ; then return $E_FEED_NOT_FOUND fi if allEmpty "${ARGS[newName]}" "${ARGS[source-type]}" "${ARGS[uri]}" "${ARGS[enabled]}" "${ARGS[trusted]}" ; then return $E_INVALID_ARG fi if [[ "${ARGS[newName]}" != "${ARGS[name]}" ]] && feedExists "${ARGS[newName]}" ; then if [[ "${ARGS[clobber]}" != '1' ]] ; then return $E_FEED_ALREADY_EXISTS else removeFeed "${ARGS[newName]}" fi fi local feedRegex feedRegex=$( createFeedLineRegex "${ARGS[name]}" ) local matchingFeed=$( grep --perl-regexp --only-matching --no-filename "$feedRegex" "${ARGS[confDir]}"/*.conf ) if ! decomposeFeedLine "$matchingFeed" ; then return $E_GENERIC_ERROR fi local feedLine=$( formatFeedLine \ "${ARGS[enabled]:-${DECOMP_FEED[enabled]}}" \ "${ARGS[source-type]:-${DECOMP_FEED[source-type]}}" \ "${ARGS[newName]:-${DECOMP_FEED[name]}}" \ "${ARGS[uri]:-${DECOMP_FEED[uri]}}" \ "${ARGS[trusted]:-${DECOMP_FEED[trusted]}}" \ "${DECOMP_FEED[extra]}" ) feedLine=$( escapeRegex "$feedLine" ) local delim=$( printf '\007' ) if ! sed --regexp-extended --in-place "s${delim}$feedRegex${delim}$feedLine${delim}" "${ARGS[confDir]}"/*.conf ; then return $E_GENERIC_ERROR fi return $S_OK } removeFeed() { if [ -z "$1" ] ; then return $E_INVALID_ARG fi local feedRegex=$( createFeedLineRegex "$1" ) grep --perl-regexp --only-matching --no-filename "$feedRegex" "${ARGS[confDir]}"/*.conf &>/dev/null local ret=$? if [[ "$ret" == "$IE_GREP_NO_MATCH" ]] ; then return $E_FEED_NOT_FOUND elif [[ "$ret" != "$IE_GREP_MATCH" ]] ; then return $E_GENERIC_ERROR fi local delim=$( printf '\007' ) if ! sed --regexp-extended --in-place "\\${delim}$feedRegex${delim}d" "${ARGS[confDir]}"/*.conf ; then return $E_GENERIC_ERROR fi find "${ARGS[confDir]}" -size 0c -exec rm -f {} + return $S_OK } # ------------------------------------------------------------------------------ # Argument processing # ------------------------------------------------------------------------------ parseArguments() { local longOptions="$1"',conf-dir:,help' local shortOptions='h' shift local opts opts=$( getopt -ql "$longOptions" -- "$shortOptions" "$@" ) || exit 3 eval set -- "$opts" ARGS[confDir]="/etc/opkg" while true; do case "$1" in --clobber) ARGS[clobber]='1' shift ;; --conf-dir) ARGS[confDir]="$2" shift 2 ;; --enabled) ARGS[enabled]="$( parseBool "$2" )" if [ -z "${ARGS[enabled]}" ] ; then exit $E_INVALID_ARG fi shift 2 ;; -h|--help) printUsage exit $S_OK ;; --key-value) ARGS[kv]='1' shift ;; --name) ARGS[newName]="$2" if [[ "${ARGS[newName]}" =~ $DOUBLE_QUOTE_PATTERN ]] ; then exit $E_INVALID_ARG fi shift 2 ;; --source-type) ARGS[source-type]="$2" case "${ARGS[source-type]}" in src|src/gz|dist|dist/gz) ;; *) exit $E_INVALID_ARG ;; esac shift 2 ;; --trusted) ARGS[trusted]="$( parseBool "$2" )" if [ -z "${ARGS[trusted]}" ] ; then exit $E_INVALID_ARG fi shift 2 ;; --uri) ARGS[uri]="$2" if [[ "${ARGS[uri]}" =~ $DOUBLE_QUOTE_PATTERN ]] ; then exit $E_INVALID_ARG fi shift 2 ;; --) shift break ;; *) exit $E_INVALID_ARG esac done } # ------------------------------------------------------------------------------ # main() # ------------------------------------------------------------------------------ if [ -z "$1" ] ; then printUsage exit $E_INVALID_ARG fi # Disable history expansion set +H case "$1" in list) shift parseArguments 'key-value' "$@" listFeeds ;; add) ARGS[name]="$2" if [[ "${ARGS[name]}" =~ $DOUBLE_QUOTE_PATTERN ]] ; then exit $E_INVALID_ARG fi shift 2 parseArguments 'clobber,enabled:,source-type:,trusted:,uri:' "$@" addFeed ;; modify) ARGS[name]="$2" if [[ "${ARGS[name]}" =~ $DOUBLE_QUOTE_PATTERN ]] ; then exit $E_INVALID_ARG fi shift 2 parseArguments 'clobber,enabled:,name:,source-type:,trusted:,uri:' "$@" modifyFeed ;; remove) parseArguments '' "$@" if [[ "$2" =~ $DOUBLE_QUOTE_PATTERN ]] ; then exit $E_INVALID_ARG fi removeFeed "$2" ;; help) printUsage exit $S_OK ;; *) parseArguments '' "$@" exit $E_INVALID_ARG ;; esac exit $?