#!/bin/bash
#
# Manage the privacy whitelist (PRIVACY_WHITELIST table) of the privilege
# database. Run with --help for usage.

set -o errexit -o nounset -o pipefail

PATH=/bin:/usr/bin:/sbin:/usr/sbin

readonly PROGRAM_NAME=${0##*/}

tz_sys_ro_share=$(tzplatform-get TZ_SYS_RO_SHARE)   # "TZ_SYS_RO_SHARE=/usr/share"
readonly PRIVILEGE_DB_DIR=${tz_sys_ro_share#*=}/privilege-manager
readonly PRIVILEGE_DB=$PRIVILEGE_DB_DIR/.privilege.db
readonly PRIVACY_WHITELIST_AWK=$PRIVILEGE_DB_DIR/privacy_whitelist.awk

readonly PRIVACY_INFO_TABLE=PRIVACY_INFO
readonly PRIVACY_WHITELIST_TABLE=PRIVACY_WHITELIST

# Column separator for sqlite3 output parsed by this script (ASCII "unit
# separator"), chosen so it cannot clash with characters in the values.
readonly FIELD_SEPARATOR=$'\x1f'

# Column widths of the "list" output. A longer PKG_ID still prints in full,
# it just shifts the rest of its row.
readonly LIST_PKG_ID_WIDTH=46       # longest PKG_ID in common_privacy_whitelist.csv
readonly LIST_CERT_LEVEL_WIDTH=8    # "platform"


# ==========================================================================
# Helpers
# ==========================================================================

usage() {
    cat <<EOF
Manage the privacy whitelist in $PRIVILEGE_DB.

Usage:
  $PROGRAM_NAME add -e ENTRY
  $PROGRAM_NAME remove -p PKG_ID
  $PROGRAM_NAME list [-p PKG_ID]
  $PROGRAM_NAME list-privacies
  $PROGRAM_NAME --help

Commands (short name in parentheses):
  add (a)              Add one package to the whitelist.
  remove (r)           Remove a package from the whitelist.
  list (l)             List the whitelist entry of a package, or of all
                       packages when -p is not given, as:
                           PKG_ID, CERT_LEVEL, +privacy -privacy ..., SETTABLE
  list-privacies (lp)  List the available privacies, as:
                           PRIVACY_ID, PRIVACY_NAME

Options:
  -e ENTRY      Whitelist entry, in the whitelist csv format:
                    PKG_ID,CERT_LEVEL,PRIVACY_OPTION,SETTABLE
                e.g. "org.tizen.calendar,platform,*-location,0"
  -p PKG_ID     Package id.
  -h, --help    Show this help (also accepted after a command).
EOF
}

die() {
    printf '%s: error: %s\n' "$PROGRAM_NAME" "$1" >&2
    exit 1
}

usage_error() {
    printf '%s: %s\n' "$PROGRAM_NAME" "$1" >&2
    printf "Try '%s --help' for more information.\n" "$PROGRAM_NAME" >&2
    exit 2
}

# Fail if anything is left on the command line after the options.
no_more_arguments() {
    if (( $# > 0 )); then
        usage_error "unexpected argument: $1"
    fi
}

# Quote a value as an SQL string literal.
sql_quote() {
    local escaped=${1//\'/\'\'}
    printf "'%s'" "$escaped"
}

# Run SQL on the privilege database, printing the result rows with
# FIELD_SEPARATOR between columns.
run_sql() {
    sqlite3 -batch -separator "$FIELD_SEPARATOR" "$PRIVILEGE_DB" "$1"
}

cert_level_name() {
    case $1 in
        0) echo public ;;
        1) echo partner ;;
        2) echo platform ;;
        *) die "unknown cert level in the database: $1" ;;
    esac
}


# ==========================================================================
# Environment checks
# ==========================================================================

# For the read-only commands.
check_database_readable() {
    # sqlite3 would silently create an empty database, so check first.
    if [[ ! -f $PRIVILEGE_DB ]]; then
        die "privilege database not found: $PRIVILEGE_DB"
    fi
    if [[ ! -r $PRIVILEGE_DB ]]; then
        die "no permission to read $PRIVILEGE_DB"
    fi
}

# For the commands that modify the database.
check_database_writable() {
    check_database_readable

    # The root file system may be mounted read-only on Tizen. Check this
    # before the permissions, as a read-only mount fails those checks too.
    local mount_point mount_options
    mount_point=$(findmnt --noheadings --output TARGET --target "$PRIVILEGE_DB")
    mount_options=$(findmnt --noheadings --output OPTIONS --target "$PRIVILEGE_DB")
    if [[ ,$mount_options, == *,ro,* ]]; then
        die "$mount_point is mounted read-only, remount it read-write with:
    mount -o remount,rw $mount_point"
    fi

    if [[ ! -w $PRIVILEGE_DB ]]; then
        die "no permission to write $PRIVILEGE_DB"
    fi
    # sqlite3 writes its rollback journal next to the database. Tizen's
    # sqlite3 keeps the journal file between transactions (journal_mode
    # persist), so the directory only needs to be writable to create it.
    local journal=$PRIVILEGE_DB-journal
    if [[ -e $journal ]]; then
        if [[ ! -w $journal ]]; then
            die "no permission to write $journal"
        fi
    elif [[ ! -w $PRIVILEGE_DB_DIR ]]; then
        die "no permission to write to $PRIVILEGE_DB_DIR (needed to create the database journal)"
    fi
}


# ==========================================================================
# Privacies
# ==========================================================================

readonly PRIVACY_LIST_SQL="select distinct PRIVACY_ID, PRIVACY_NAME from $PRIVACY_INFO_TABLE"

# privacy_names[PRIVACY_ID] = short privacy name, e.g. privacy_names[7]=location
declare -a privacy_names

load_privacies() {
    local rows id full_name
    rows=$(run_sql "$PRIVACY_LIST_SQL")
    if [[ -z $rows ]]; then
        die "no privacies in the database"
    fi

    while IFS=$FIELD_SEPARATOR read -r id full_name; do
        # "http://tizen.org/privacy/location" -> "location"
        privacy_names[id]=${full_name##*/}
    done <<< "$rows"
}

# Turn a PRIVACY_OPTION value (e.g. "1111111011111") into the list of privacies
# with their state ("+account +bookmark ... -location ...").
format_privacy_option() {
    local privacy_option=$1
    local id bit
    local -a formatted=()

    # Indices of a bash array are listed in ascending order.
    for id in "${!privacy_names[@]}"; do
        bit=${privacy_option:id:1}
        case $bit in
            1) formatted+=("+${privacy_names[id]}") ;;
            0) formatted+=("-${privacy_names[id]}") ;;
            *) die "invalid privacy option in the database: '$privacy_option'" ;;
        esac
    done

    # "${array[*]}" joins the elements with the first character of IFS.
    local IFS=' '
    echo "${formatted[*]}"
}


# ==========================================================================
# Commands
# ==========================================================================

command_add() {
    local entry=""
    local option OPTIND=1
    while getopts ':e:h' option; do
        case $option in
            e) entry=$OPTARG ;;
            h) usage; exit 0 ;;
            :) usage_error "option -$OPTARG requires an argument" ;;
            *) usage_error "unknown option: -$OPTARG" ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    no_more_arguments "$@"
    if [[ -z $entry ]]; then
        usage_error "add: missing -e ENTRY"
    fi

    check_database_writable
    if [[ ! -r $PRIVACY_WHITELIST_AWK ]]; then
        die "script not found: $PRIVACY_WHITELIST_AWK"
    fi

    # privacy_whitelist.awk expects the privacy list in sqlite3's default
    # "id|name" format, and reports a bad entry itself on stderr.
    local insert_sql
    insert_sql=$(sqlite3 -batch "$PRIVILEGE_DB" "$PRIVACY_LIST_SQL" |
        WHITELIST_ENTRY=$entry awk -f "$PRIVACY_WHITELIST_AWK" -)

    run_sql "$insert_sql"
}

command_remove() {
    local pkg_id=""
    local option OPTIND=1
    while getopts ':p:h' option; do
        case $option in
            p) pkg_id=$OPTARG ;;
            h) usage; exit 0 ;;
            :) usage_error "option -$OPTARG requires an argument" ;;
            *) usage_error "unknown option: -$OPTARG" ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    no_more_arguments "$@"
    if [[ -z $pkg_id ]]; then
        usage_error "remove: missing -p PKG_ID"
    fi

    check_database_writable

    local quoted_pkg_id removed_count
    quoted_pkg_id=$(sql_quote "$pkg_id")
    removed_count=$(run_sql "
        delete from $PRIVACY_WHITELIST_TABLE where PKG_ID = $quoted_pkg_id;
        select changes();")

    if (( removed_count == 0 )); then
        die "package not in the whitelist: $pkg_id"
    fi
}

command_list() {
    local pkg_id=""
    local option OPTIND=1
    while getopts ':p:h' option; do
        case $option in
            p) pkg_id=$OPTARG ;;
            h) usage; exit 0 ;;
            :) usage_error "option -$OPTARG requires an argument" ;;
            *) usage_error "unknown option: -$OPTARG" ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    no_more_arguments "$@"

    check_database_readable
    load_privacies

    local condition="" quoted_pkg_id
    if [[ -n $pkg_id ]]; then
        quoted_pkg_id=$(sql_quote "$pkg_id")
        condition="where PKG_ID = $quoted_pkg_id"
    fi

    local rows
    rows=$(run_sql "
        select PKG_ID, CERT_LEVEL, PRIVACY_OPTION, SETTABLE
        from $PRIVACY_WHITELIST_TABLE $condition
        order by PKG_ID")

    if [[ -z $rows ]]; then
        if [[ -n $pkg_id ]]; then
            die "package not in the whitelist: $pkg_id"
        fi
        return
    fi

    local row_pkg_id cert_level privacy_option settable
    local cert_level_string privacies
    while IFS=$FIELD_SEPARATOR read -r row_pkg_id cert_level privacy_option settable; do
        cert_level_string=$(cert_level_name "$cert_level")
        privacies=$(format_privacy_option "$privacy_option")
        # Each comma stays right after its value, the padding follows it
        # (hence the "+ 1" in the widths).
        printf '%-*s %-*s %s, %s\n' \
            $(( LIST_PKG_ID_WIDTH + 1 )) "$row_pkg_id," \
            $(( LIST_CERT_LEVEL_WIDTH + 1 )) "$cert_level_string," \
            "$privacies" "$settable"
    done <<< "$rows"
}

command_list_privacies() {
    local option OPTIND=1
    while getopts ':h' option; do
        case $option in
            h) usage; exit 0 ;;
            *) usage_error "unknown option: -$OPTARG" ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    no_more_arguments "$@"

    check_database_readable
    load_privacies

    local id
    for id in "${!privacy_names[@]}"; do
        printf '%s, %s\n' "$id" "${privacy_names[id]}"
    done
}


# ==========================================================================
# Main
# ==========================================================================

main() {
    if (( $# == 0 )); then
        usage_error "missing command"
    fi

    local command=$1
    shift
    case $command in
        add | a)              command_add "$@" ;;
        remove | r)           command_remove "$@" ;;
        list | l)             command_list "$@" ;;
        list-privacies | lp)  command_list_privacies "$@" ;;
        -h | --help)          usage ;;
        *)                    usage_error "unknown command: $command" ;;
    esac
}

main "$@"
