How can I make sure a string contains at least one uppercase letter, one lowercase letter, one number and one punctuation character?

With flexible awk pattern matching:

if [[ $(echo "$string" | awk '/[a-z]/ && /[A-Z]/ && /[0-9]/ && /[[:punct:]]/') ]]; then  
    echo "String meets your requirements"
else 
    echo "String does not meet your requirements"
fi

With one call to awk and without pipe:

#! /bin/sh -
string='whatever'

has_char_of_each_class() {
  LC_ALL=C awk -- '
    BEGIN {
      for (i = 2; i < ARGC; i++)
        if (ARGV[1] !~ "[[:" ARGV[i] ":]]") exit 1
    }' "$@"
}

if has_char_of_each_class "$string" lower upper digit punct; then
  echo OK
else
  echo not OK
fi

That's POSIX but note that mawk doesn't support POSIX character classes yet. The -- is not needed with POSIX compliant awks but would be in older versions of busybox awk (which would choke on values of $string that start with -).

A variant of that function using a case shell construct:

has_char_of_each_class() {
  input=$1; shift
  for class do
    case $input in
      (*[[:$class:]]*) ;;
      (*) return 1;;
    esac
  done
}

Note however that changing the locale for the shell in the middle of a script doesn't work with all sh implementations (so you'd need the script to be called in the C locale already if you want the input to be considered as being encoded in the C locale charset and the character classes to match only the ones specified by POSIX).


The following script is longer than your code, but shows how you could test a string against a list of patterns. The code detects whether the string matches all patterns or not and prints out a result.

#!/bin/sh

string=TestString1

failed=false

for pattern in '*[[:upper:]]*' '*[[:lower:]]*' '*[[:digit:]]*' '*[[:punct:]]*'
do
    case $string in
        $pattern) ;;
        *)
            failed=true
            break
    esac
done

if "$failed"; then
    printf '"%s" does not meet the requirements\n' "$string"
else
    printf '"%s" is ok\n' "$string"
fi

The case ... esac compound command is the POSIX way to test a string against a set of globbing patterns. The variable $pattern is used unquoted in the test, so that the match is not done as a string comparison. If the string does not match the given pattern, then it will match *, and the loop is exited after setting failed to true.

Running this would yield

$ sh script.sh
"TestString1" does not meet the requirements

You could tuck the testing away in a function like so (the code tests a number of strings in a loop, calling the function):

#!/bin/sh

test_string () {
    for pattern in '*[[:upper:]]*' '*[[:lower:]]*' '*[[:digit:]]*' '*[[:punct:]]*'
    do
        case $1 in ($pattern) ;; (*) return 1; esac
    done
}

for string in TestString1 Test.String2 TestString-3; do
    if ! test_string "$string"; then
        printf '"%s" does not meet the requirements\n' "$string"
    else
        printf '"%s" is ok\n' "$string"
    fi
done

If you want to set LC_ALL=C locally in the function, write it as

test_string () (
    LC_ALL=C

    for pattern in '*[[:upper:]]*' '*[[:lower:]]*' '*[[:digit:]]*' '*[[:punct:]]*'
    do
        case $1 in ($pattern) ;; (*) return 1; esac
    done
)

Note that the body of the function now is in a sub-shell. Setting LC_ALL=C will therefore not affect the value of this variable in the calling environment.

Get the shell function to take the patterns as arguments too, and you basically get Stéphane Chazelas' answer (the variant).