SHA256SUMS
SHA256SUMS

Reputation:

How do I use escapeshellarg() on Windows but "aimed for Linux" (and vice versa)?

If PHP is running on Windows, escapeshellarg() escapes file names (for example) in a certain way and then adds " (DOUBLE) quotes around it.

If PHP is running on Linux, escapeshellarg() uses Linux-based escaping and then adds ' (SINGLE) quotes around it.

In my situation, I'm generating a SHA256SUMS file on Windows, but aimed for Linux. Since I use escapeshellarg() to escape the file name, I end up with a file like:

cabcdccas12exdqdqadanacvdkjsc123ccfcfq3rdwcndwf2qefcf "cool filename with spaces.zip"

However, Linux tools probably expect:

cabcdccas12exdqdqadanacvdkjsc123ccfcfq3rdwcndwf2qefcf 'cool filename with spaces.zip'

Looking in the manual, there seems to be no way to do something like: escapeshellarg($blabla, TARGET_OS_LINUX); in order for it to use the rules for Linux instead of the OS running the script (Windows).

I can't just str_replace the quotes because it would not take into consideration all the platform-specific rules.

Also, yes, I need spaces in the file name (and any other cross-platform-valid character).

I sadly found no mention whatsoever about the preferred quote style on the only source of information I have for this: https://help.ubuntu.com/community/HowToSHA256SUM

Maybe the SHA256 security verification tools which read that SHA256SUMS file understand and can parse both kinds?

Upvotes: 3

Views: 1567

Answers (1)

Deltik
Deltik

Reputation: 1137

The behavior of escapeshellarg() is hard-coded depending on whether PHP is running on Windows or any other operating system. You should reimplement escapeshellarg() for consistent behavior.

Here is my attempt at reimplementing escapeshellarg() with a Windows/other-OS toggle in PHP:

<?php namespace polyfill;

const TARGET_OS_WINDOWS = 1;
const TARGET_OS_UNIX    = 2;

function escapeshellarg(string $input, int $os_mode = 0): string
{
    if (false !== strpos($input, "\x00"))
    {
        throw new \UnexpectedValueException(__FUNCTION__ . '(): Argument #1 ($input) must not contain any null bytes');
    }
    
    if ($os_mode == 0)
    {
        $os_mode = TARGET_OS_UNIX;
        if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN')
            $os_mode = TARGET_OS_WINDOWS;
    }
    
    $maxlen = 4096;
    if ($os_mode === TARGET_OS_WINDOWS) $maxlen = 8192;
    if (strlen($input) > $maxlen - 2) return "";

    if ($os_mode === TARGET_OS_WINDOWS)
    {
        $output =
            str_replace(['"', '%', '!'],
                        [' ', ' ', ' '],
                        $input);

        # https://bugs.php.net/bug.php?id=69646
        if (substr($output, -1) === "\\")
        {
            $k = 0; $n = strlen($output) - 1;
            for (; $n >= 0 && substr($output, $n, 1) === "\\"; $n--, $k++);
            if ($k % 2) $output .= "\\";
        }
        
        $output = "\"$output\"";
    }
    else
    {
        $output = str_replace("'", "'\''", $input);
        
        $output = "'$output'";
    }
    
    if (strlen($output) > $maxlen) return "";
    return $output;
}

It should be almost functionally equivalent to the native PHP escapeshellarg(), except that:

  • it takes a second argument that sets whether you want the output in Windows mode or not Windows mode,
  • it raises an \UnexpectedValueException instead of some kind of PHP error if the input string contains null bytes,
  • it doesn't emit errors due to the input being too long, and
  • it has 4096 hard-coded as the maximum argument length on Unix-like platforms.

To use this replacement function:

# In Unix/Linux/macOS mode
\polyfill\escapeshellarg($blabla, \polyfill\TARGET_OS_UNIX);

# In Windows mode
\polyfill\escapeshellarg($blabla, \polyfill\TARGET_OS_WINDOWS);

# In auto-detect (running OS) mode
\polyfill\escapeshellarg($blabla);

Reference

Here is the full C implementation from PHP 7.3.10 (./ext/standard/exec.c):

PHPAPI zend_string *php_escape_shell_arg(char *str)
{
    size_t x, y = 0;
    size_t l = strlen(str);
    zend_string *cmd;
    uint64_t estimate = (4 * (uint64_t)l) + 3;

    /* max command line length - two single quotes - \0 byte length */
    if (l > cmd_max_len - 2 - 1) {
        php_error_docref(NULL, E_ERROR, "Argument exceeds the allowed length of %zu bytes", cmd_max_len);
        return ZSTR_EMPTY_ALLOC();
    }

    cmd = zend_string_safe_alloc(4, l, 2, 0); /* worst case */

#ifdef PHP_WIN32
    ZSTR_VAL(cmd)[y++] = '"';
#else
    ZSTR_VAL(cmd)[y++] = '\'';
#endif

    for (x = 0; x < l; x++) {
        int mb_len = php_mblen(str + x, (l - x));

        /* skip non-valid multibyte characters */
        if (mb_len < 0) {
            continue;
        } else if (mb_len > 1) {
            memcpy(ZSTR_VAL(cmd) + y, str + x, mb_len);
            y += mb_len;
            x += mb_len - 1;
            continue;
        }

        switch (str[x]) {
#ifdef PHP_WIN32
        case '"':
        case '%':
        case '!':
            ZSTR_VAL(cmd)[y++] = ' ';
            break;
#else
        case '\'':
            ZSTR_VAL(cmd)[y++] = '\'';
            ZSTR_VAL(cmd)[y++] = '\\';
            ZSTR_VAL(cmd)[y++] = '\'';
#endif
            /* fall-through */
        default:
            ZSTR_VAL(cmd)[y++] = str[x];
        }
    }
#ifdef PHP_WIN32
    if (y > 0 && '\\' == ZSTR_VAL(cmd)[y - 1]) {
        int k = 0, n = y - 1;
        for (; n >= 0 && '\\' == ZSTR_VAL(cmd)[n]; n--, k++);
        if (k % 2) {
            ZSTR_VAL(cmd)[y++] = '\\';
        }
    }

    ZSTR_VAL(cmd)[y++] = '"';
#else
    ZSTR_VAL(cmd)[y++] = '\'';
#endif
    ZSTR_VAL(cmd)[y] = '\0';

    if (y > cmd_max_len + 1) {
        php_error_docref(NULL, E_ERROR, "Escaped argument exceeds the allowed length of %zu bytes", cmd_max_len);
        zend_string_release_ex(cmd, 0);
        return ZSTR_EMPTY_ALLOC();
    }

    if ((estimate - y) > 4096) {
        /* realloc if the estimate was way overill
         * Arbitrary cutoff point of 4096 */
        cmd = zend_string_truncate(cmd, y, 0);
    }
    ZSTR_LEN(cmd) = y;
    return cmd;
}

// … [truncated] …

/* {{{ proto string escapeshellarg(string arg)
   Quote and escape an argument for use in a shell command */
PHP_FUNCTION(escapeshellarg)
{
    char *argument;
    size_t argument_len;

    ZEND_PARSE_PARAMETERS_START(1, 1)
        Z_PARAM_STRING(argument, argument_len)
    ZEND_PARSE_PARAMETERS_END();

    if (argument) {
        if (argument_len != strlen(argument)) {
            php_error_docref(NULL, E_ERROR, "Input string contains NULL bytes");
            return;
        }
        RETVAL_STR(php_escape_shell_arg(argument));
    }
}
/* }}} */

The logic is fairly simple. Here are some equivalent functional test cases in prose:

  • The input string cannot contain NUL characters.
  • Applied to the input string,
    • in Windows mode,
      • Prepend a " character.
      • Replace all ", %, and ! characters with .
      • If the end consists of an odd number of \ characters, add one \ character to the end. (Bug #69646)
      • Append a " character.
    • in other platforms mode,
      • Prepend a ' character.
      • Replace all ' characters with '\''
      • Append a ' character.
  • On Windows, if the output is longer than 8192 characters, emit an E_ERROR and return an empty string.
  • On other platforms, if the output is longer than 4096 characters (or whatever the overridden maximum is at compile time), emit an E_ERROR and return an empty string.

Upvotes: 3

Related Questions