#!/bin/sh

# Accounts of the system, read out of the tree and changed with `shadow-utils`.
#
# The name of the resource is the login name.  As with groups, what is read is
# `$DETC_ROOT/etc/passwd` and its neighbours rather than `getent`, so that a
# tree being built is described by what is in it; what is written is `useradd`
# and its family, which take the root as an argument and hold the locks.
#
# The primary group is declared by name and not by number, because that is how a
# fleet writes it and because the number a distribution gives a group is not the
# same on every one of them.  A resource of type `group`, at order 20, has
# already made it by the time this runs.
#
# `groups` says every group the account belongs to and not merely some of them,
# so a group that is not listed is one the account is taken out of.  Membership
# is a set and a list is not, so what is reported back names the declared groups
# first and in the order they were declared: a declaration that spells the same
# set in another order is the same state and has to compare equal.
#
# `password` is the hash, the way `/etc/shadow` holds it, and never a password:
# there is nothing here that could hash one, and a provider that ran a hashing
# tool would be putting the salt in the declaration.  It is reported back only
# when a declaration asked about it, so that a hash does not end up in the
# record of a run that was about a login shell.

set -eu

fail() {
    echo "user: $*" >&2
    exit 1
}

# The state that the declaration asks for, as `key<tab>value` lines.
#
# This is the same reader as in the other core providers, copied rather than
# shared, because a provider is one executable and a fleet replaces it by
# dropping one file of its own into a prefix.  A change to one of them is a
# change to all of them.
#
# Only the object under `desired` is read, and only the shapes that a schema
# here declares: strings, numbers, booleans, and arrays of those, which become
# their elements joined by a comma.  The request arrives compact, the way detc
# writes it, so there is no whitespace to step over.
desired() {
    awk '
{
  s = $0
  n = length(s)
  i = index(s, "\"desired\":{") + 11
  if (i == 11 || substr(s, i, 1) == "}") exit
  while (i <= n) {
    k = str()
    i++
    print k "\t" val()
    if (substr(s, i, 1) != ",") exit
    i++
  }
}

function str(   out, c) {
  i++
  out = ""
  while (i <= n) {
    c = substr(s, i, 1)
    i++
    if (c == "\"") return out
    if (c == "\\") {
      c = substr(s, i, 1)
      i++
      if (c == "n") c = "\n"
      else if (c == "t") c = "\t"
      else if (c == "u") { c = " "; i += 4 }
    }
    out = out c
  }
  return out
}

function val(   out, c) {
  c = substr(s, i, 1)
  if (c == "\"") return str()
  if (c == "{") { print "an object is not a property this reads" > "/dev/stderr"; exit 1 }
  if (c == "[") {
    i++
    out = ""
    if (substr(s, i, 1) == "]") { i++; return out }
    while (i <= n) {
      out = out val()
      c = substr(s, i, 1)
      i++
      if (c == "]") return out
      out = out ","
    }
    return out
  }
  out = ""
  while (i <= n && index(",}", substr(s, i, 1)) == 0) {
    out = out substr(s, i, 1)
    i++
  }
  return out
}
'
}

# The name of the object, out of the request.  The keys arrive in the order that
# a JSON map sorts them, which puts `name` after `current`, `desired` and
# `diff` -- last, in both requests -- so the one at the end of the line is the
# top level one and a `name` inside the declaration is not mistaken for it.
name_of() {
    sed -n 's/.*,"name":"\([^"]*\)"}$/\1/p'
}

yaml() {
    printf '%s: "%s"\n' "$1" "$(printf '%s' "$2" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
}

# A comma separated list, as the YAML list that the schema says it is.  An empty
# one is written on the same line, because a mapping key with nothing under it
# is null and null is not a list.
yaml_list() {
    if [ -z "$2" ]; then
        printf '%s: []\n' "$1"
        return
    fi
    printf '%s:\n' "$1"
    printf '%s\n' "$2" | tr ',' '\n' | while read -r item; do
        printf -- '- "%s"\n' "$(printf '%s' "$item" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
    done
}

root=${DETC_ROOT:-/}

shadow() {
    program=$1
    shift
    if [ "$root" = / ]; then
        "$program" "$@"
    else
        "$program" --root "$root" "$@"
    fi
}

# One field of the line of `/etc/passwd` that names the account.
passwd_field() {
    awk -F: -v name="$1" -v field="$2" \
        '$1 == name { print $field; exit }' "$root/etc/passwd" 2>/dev/null || true
}

# The name of the group whose number this is, so that what is reported is the
# name that a declaration writes.
group_name() {
    awk -F: -v gid="$1" '$3 == gid { print $1; exit }' "$root/etc/group" 2>/dev/null || true
}

# Every group that lists the account as a member: the ones `$2` named, in that
# order, and then whatever else is there.  What is left over is what makes the
# resource differ, and it is gone by the time it has been applied.
memberships() {
    awk -F: -v name="$1" -v want="$2" '
        {
            count = split($4, members, ",")
            for (m = 1; m <= count; m++) if (members[m] == name) { has[$1] = 1; break }
        }
        END {
            count = split(want, wanted, ",")
            for (m = 1; m <= count; m++) {
                if (wanted[m] != "" && (wanted[m] in has)) {
                    list = list sep wanted[m]
                    sep = ","
                    delete has[wanted[m]]
                }
            }
            for (group in has) { list = list sep group; sep = "," }
            print list
        }
    ' "$root/etc/group" 2>/dev/null || true
}

# The hash of the account.  `/etc/shadow` has no field saying that an account is
# locked: it puts a `!` in front of the hash instead.
shadow_hash() {
    awk -F: -v name="$1" '$1 == name { print $2; exit }' "$root/etc/shadow" 2>/dev/null || true
}

# The value that the declaration gave for a property, or nothing.
declared() {
    printf '%s' "$request" | desired | awk -F'\t' -v k="$1" '$1 == k { print $2; exit }'
}

# Whether it named the property at all, which is not the same as having given
# it something: an empty list and no list are different declarations.
mentions() {
    printf '%s' "$request" | desired | awk -F'\t' -v k="$1" '$1 == k { found = 1 } END { exit !found }'
}

case "${1-}" in
  schema)
    cat <<'EOF'
description: An account of the system
order: 25
properties:
  uid:
    type: integer
    description: The number of the account, when it has to be a particular one
  group:
    type: string
    description: The primary group, by name
  groups:
    type: array
    description: >-
      Every other group the account belongs to, by name.  A group that is not
      listed is one the account is taken out of, so an empty list means that it
      belongs to none.
  shell:
    type: string
    description: The login shell
  home:
    type: string
    description: The home directory
  comment:
    type: string
    description: The GECOS field, which is usually the full name
  password:
    type: string
    description: >-
      The hash, exactly as /etc/shadow holds it, and never a password.  An
      account whose password cannot be used is `!`, and one that has none at all
      is the empty string.  It is only reported back when it is declared.
  locked:
    type: boolean
    description: Whether the account is locked, which is a `!` in front of the hash
  system:
    type: boolean
    description: >-
      Whether the account is one of the system, which decides the range that its
      number comes from and whether a home directory is made, and therefore only
      means anything at the moment the account is created.  Nothing records it
      afterwards, so it is reported back as it was declared.
    default: false
  present:
    type: boolean
    description: Whether the account has to be in the system
    default: true
EOF
    ;;

  inspect)
    request=$(cat)
    name=$(printf '%s' "$request" | name_of)
    [ -n "$name" ] || fail "the request does not name an account"

    uid=$(passwd_field "$name" 3)
    if [ -z "$uid" ]; then
        echo "present: false"
        exit 0
    fi

    echo "present: true"
    echo "uid: $uid"
    yaml group "$(group_name "$(passwd_field "$name" 4)")"
    yaml comment "$(passwd_field "$name" 5)"
    yaml home "$(passwd_field "$name" 6)"
    yaml shell "$(passwd_field "$name" 7)"

    ! mentions groups || yaml_list groups "$(memberships "$name" "$(declared groups)")"

    hash=$(shadow_hash "$name")
    case "$hash" in
      "!"*) echo "locked: true" ;;
      *) echo "locked: false" ;;
    esac

    # The hash is only reported when the declaration is about it, so that a run
    # that moved a login shell does not copy /etc/shadow into its own record
    ! mentions password || yaml password "${hash#!}"

    system=$(declared system)
    [ -z "$system" ] || echo "system: $system"
    ;;

  apply)
    request=$(cat)
    name=$(printf '%s' "$request" | name_of)
    [ -n "$name" ] || fail "the request does not name an account"

    uid=
    group=
    groups=
    shell=
    home=
    comment=
    password=
    locked=
    system=false
    present=true
    while IFS='	' read -r key value; do
        case "$key" in
          uid) uid=$value ;;
          group) group=$value ;;
          shell) shell=$value ;;
          home) home=$value ;;
          comment) comment=$value ;;
          password) password=$value ;;
          locked) locked=$value ;;
          system) system=$value ;;
          present) present=$value ;;
        esac
    done <<EOF
$(printf '%s' "$request" | desired)
EOF

    if [ "$present" != true ]; then
        [ -z "$(passwd_field "$name" 3)" ] || shadow userdel --remove "$name"
        exit 0
    fi

    # The same options name the same things for `useradd` and for `usermod`, so
    # what differs is only which of them is called and what a new account
    # additionally needs
    set --
    [ -z "$uid" ] || set -- "$@" --uid "$uid"
    [ -z "$group" ] || set -- "$@" --gid "$group"
    [ -z "$shell" ] || set -- "$@" --shell "$shell"
    [ -z "$home" ] || set -- "$@" --home-dir "$home"
    [ -z "$comment" ] || set -- "$@" --comment "$comment"
    [ -z "$password" ] || set -- "$@" --password "$password"

    if mentions groups; then
        groups=$(printf '%s' "$request" | desired | awk -F'\t' '$1 == "groups" { print $2; exit }')
        set -- "$@" --groups "$groups"
    fi

    if [ -n "$(passwd_field "$name" 3)" ]; then
        # A home directory that moves takes what is in it along, which is what
        # anybody asking for a different one meant.  Only when it really moves:
        # `usermod` asked to move a directory onto itself is an error, and a run
        # that was about a login shell would hit it
        [ -z "$home" ] || [ "$home" = "$(passwd_field "$name" 6)" ] || set -- "$@" --move-home
        [ "$#" -eq 0 ] || shadow usermod "$@" "$name"
    else
        if [ "$system" = true ]; then
            set -- "$@" --system
        else
            set -- "$@" --create-home
        fi
        # Without a primary group of its own the account would land in whatever
        # the distribution made the default, which is a decision and not a
        # default
        [ -n "$group" ] || set -- "$@" --user-group
        shadow useradd "$@" "$name"
    fi

    case "$locked" in
      true) shadow usermod --lock "$name" ;;
      false) shadow usermod --unlock "$name" ;;
    esac
    ;;

  "")
    fail "no verb, expected schema, inspect or apply"
    ;;

  *)
    fail "unknown verb $1, expected schema, inspect or apply"
    ;;
esac
