From 37bda06eedb789df05c5a318460341e41ca48655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herm=C3=A8s=20B=C3=A9lusca-Ma=C3=AFto?= Date: Sun, 26 Jul 2020 20:30:21 +0200 Subject: [PATCH] [CMD] CALL: Fix the implementation of the CALL command, make it more compatible with Windows' CMD. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fail if no parameter is provided. - The "CALL :label args..." syntax is available only when command extensions are enabled. Fail if this syntax is used outside of a batch context. - Reparse the CALL command parameter with the command parser, in order to accurately parse and interpret it as a possible command (including escape carets, etc...) and not duplicate the logic. ** CURRENT Windows' CMD-compatibility LIMITATION ** (may be lifted in a "ROS-specific" running mode of CMD): only allow standard commands to be specified as parameter of the CALL command. This reparsing behaviour can be observed in Windows' CMD, by dumping the interpreted commands after enabling the cmd!fDumpParse flag from a debugger (using public symbols). - When reparsing, we should tell the parser to NOT ignore lines that start with a colon, because in this situation these are to be considered as valid "commands" (for parsing "CALL :label"). * For Windows' CMD-compatibility, the remaining escape carets need to be doubled again so that, after the new parser step, they are escaped back to their original form. But then we also need to do it the "buggy" way à la Windows, where carets in quotes are doubled either! However when being re-parsed, since they are in quotes they remain doubled!! (see "Phase 6" in https://stackoverflow.com/a/4095133/13530036 ). * A MSCMD_CALL_QUIRKS define allows to disable this buggy behaviour, and instead tell the parser to not not interpret the escape carets. - When initializing a new batch context when the "CALL :label" syntax is used, ensure that we reuse the same batch file position pointer as its parent, so as to have correct call label ordering behaviour. That is, :label ECHO hi CALL :label :label ECHO bye should display: hi bye bye i.e., the CALL calls the second label instead of the first one (and thus entering into an infinite loop). Finally, the "CALL :label" syntax strips the first ':' away, so, as a side-effect, the command "CALL :EOF" fails (otherwise it would perform a "GOTO :EOF" and succeeds), while "CALL ::EOF" succeeds. Fixes some cmd_winetests. --- base/shell/cmd/batch.c | 33 ++++++- base/shell/cmd/call.c | 185 ++++++++++++++++++++++++++++++++++------ base/shell/cmd/cmd.h | 3 + base/shell/cmd/goto.c | 19 ++++- base/shell/cmd/parser.c | 12 ++- 5 files changed, 218 insertions(+), 34 deletions(-) diff --git a/base/shell/cmd/batch.c b/base/shell/cmd/batch.c index 1876b84deab..714480cb6c7 100644 --- a/base/shell/cmd/batch.c +++ b/base/shell/cmd/batch.c @@ -399,9 +399,28 @@ INT Batch(LPTSTR fullname, LPTSTR firstword, LPTSTR param, PARSED_COMMAND *Cmd) #endif } - /* Check if this is a "CALL :label" */ - if (*firstword == _T(':')) - ret = cmd_goto(firstword); + /* If this is a "CALL :label args ...", call a subroutine of + * the current batch file, only if extensions are enabled. */ + if (bEnableExtensions && (*firstword == _T(':'))) + { + LPTSTR expLabel; + + /* Position at the place of the parent file (which is the same as the caller) */ + bc->mempos = (bc->prev ? bc->prev->mempos : 0); + + /* + * Jump to the label. Strip the label's colon; as a side-effect + * this will forbid "CALL :EOF"; however "CALL ::EOF" will work! + */ + bc->current = Cmd; + ++firstword; + + /* Expand the label only! (simulate a GOTO command as in Windows' CMD) */ + expLabel = DoDelayedExpansion(firstword); + ret = cmd_goto(expLabel ? expLabel : firstword); + if (expLabel) + cmd_free(expLabel); + } /* If we have created a new context, don't return * until this batch file has completed. */ @@ -548,8 +567,16 @@ LPTSTR ReadBatchLine(VOID) TRACE("ReadBatchLine(): textline: \'%s\'\n", debugstr_aw(textline)); +#if 1 + // + // FIXME: This is redundant, but keep it for the moment until we correctly + // hande the end-of-file situation here, in ReadLine() and in the parser. + // (In an EOF, the previous BatchGetString() call will return FALSE but + // we want not to run the ExitBatch() at first, but wait later to do it.) + // if (textline[_tcslen(textline) - 1] != _T('\n')) _tcscat(textline, _T("\n")); +#endif return textline; } diff --git a/base/shell/cmd/call.c b/base/shell/cmd/call.c index 55f39d086f1..c9b11937492 100644 --- a/base/shell/cmd/call.c +++ b/base/shell/cmd/call.c @@ -30,58 +30,189 @@ #include "precomp.h" +/* Enable this define for "buggy" Windows' CMD CALL-command compatibility */ +#define MSCMD_CALL_QUIRKS + + /* * Perform CALL command. */ - INT cmd_call(LPTSTR param) { - TCHAR line[CMDLINE_LENGTH + 1]; - TCHAR *first; - BOOL bInQuote = FALSE; + PARSED_COMMAND* Cmd = NULL; + BOOL bOldIgnoreParserComments; +#ifndef MSCMD_CALL_QUIRKS + BOOL bOldHandleContinuations; +#else + SIZE_T nNumCarets; +#endif + PTSTR first; - TRACE ("cmd_call: (\'%s\')\n", debugstr_aw(param)); - if (!_tcsncmp (param, _T("/?"), 2)) + TRACE("cmd_call(\'%s\')\n", debugstr_aw(param)); + + if (!_tcsncmp(param, _T("/?"), 2)) { - ConOutResPaging(TRUE,STRING_CALL_HELP); + ConOutResPaging(TRUE, STRING_CALL_HELP); return 0; } - /* Do a second round of %-variable substitutions */ - if (!SubstituteVars(param, line, _T('%'))) + /* Fail if no command or label has been provided */ + if (*param == _T('\0')) return (nErrorLevel = 1); - /* Find start and end of first word */ - first = line; - while (_istspace(*first)) - first++; + /* Ignore parser comments (starting with a colon) */ + bOldIgnoreParserComments = bIgnoreParserComments; + bIgnoreParserComments = FALSE; - for (param = first; *param; param++) +#ifndef MSCMD_CALL_QUIRKS + /* Disable parsing of escape carets */ + bOldHandleContinuations = bHandleContinuations; + bHandleContinuations = FALSE; + first = param; +#else + /* + * As the original escape carets have been dealt with during the first + * command parsing step, the remaining ones need to be doubled so that + * they can again survive the new parsing step done below. + * But do it the Windows' CMD "buggy" way: **all** carets are doubled, + * even those inside quotes. However, this causes carets inside quotes + * to remain doubled after the parsing step... + */ + + /* Count all the carets */ + nNumCarets = 0; + first = param; + while (first) { - if (!bInQuote && (_istspace(*param) || _tcschr(_T(",;="), *param))) - break; - bInQuote ^= (*param == _T('"')); + first = _tcschr(first, _T('^')); + if (first) + { + ++nNumCarets; + ++first; + } } - /* Separate first word from rest of line */ - memmove(param + 1, param, (_tcslen(param) + 1) * sizeof(TCHAR)); - *param++ = _T('\0'); + /* Re-allocate a large enough parameter string if needed */ + if (nNumCarets > 0) + { + PTCHAR Src, Dest, End; - if (*first == _T(':') && bc) + // TODO: Improvement: Use the scratch TempBuf if the string is not too long. + first = cmd_alloc((_tcslen(param) + nNumCarets + 1) * sizeof(TCHAR)); + if (!first) + { + WARN("Cannot allocate memory for new CALL parameter string!\n"); + error_out_of_memory(); + return (nErrorLevel = 1); + } + + /* Copy the parameter string and double the escape carets */ + Src = param; + Dest = first; + while (*Src) + { + if (*Src != _T('^')) + { + /* Copy everything before the next caret (or the end of the string) */ + End = _tcschr(Src, _T('^')); + if (!End) + End = Src + _tcslen(Src); + memcpy(Dest, Src, (End - Src) * sizeof(TCHAR)); + Dest += End - Src; + Src = End; + continue; + } + + /* Copy the original caret and double it */ + *Dest++ = *Src; + *Dest++ = *Src++; + } + *Dest = _T('\0'); + } + else + { + first = param; + } +#endif + + /* + * Reparse the CALL parameter string as a command. + * Note that this will trigger a second round of %-variable substitutions. + */ + Cmd = ParseCommand(first); + + /* Restore the global parsing state */ +#ifndef MSCMD_CALL_QUIRKS + bHandleContinuations = bOldHandleContinuations; +#endif + bIgnoreParserComments = bOldIgnoreParserComments; + + /* + * If no command is there, yet no error occurred, this means that + * a whitespace label was given. Do not consider this as a failure. + */ + if (!Cmd && !bParseError) + { +#ifdef MSCMD_CALL_QUIRKS + if (first != param) + cmd_free(first); +#endif + return (nErrorLevel = 0); + } + + /* Reset bParseError so as to continue running afterwards */ + bParseError = FALSE; + + /* + * Otherwise, if no command is there because a parse error occurred, + * or if this an unsupported command: not a standard one, including + * FOR and IF, fail and bail out. + */ + if (!Cmd || (Cmd->Type == C_FOR) || (Cmd->Type == C_IF) || + ((Cmd->Type != C_COMMAND) && (Cmd->Type != C_REM))) + { + // FIXME: Localize + ConErrPrintf(_T("%s was unexpected.\n"), first); + +#ifdef MSCMD_CALL_QUIRKS + if (first != param) + cmd_free(first); +#endif + if (Cmd) FreeCommand(Cmd); + return (nErrorLevel = 1); + } + +#ifdef MSCMD_CALL_QUIRKS + if (first != param) + cmd_free(first); +#endif + + first = Cmd->Command.First; + param = Cmd->Command.Rest; + + /* "CALL :label args ..." - Call a subroutine of the current batch file, only if extensions are enabled */ + if (bEnableExtensions && (*first == _T(':'))) { INT ret; - /* CALL :label - call a subroutine of the current batch file */ - while (*param == _T(' ')) - param++; + /* A batch context must be present */ + if (!bc) + { + // FIXME: Localize + ConErrPuts(_T("Invalid attempt to call batch label outside of batch script.\n")); + FreeCommand(Cmd); + return (nErrorLevel = 1); + } ret = Batch(bc->BatchFilePath, first, param, NULL); nErrorLevel = (ret != 0 ? ret : nErrorLevel); - - return nErrorLevel; + } + else + { + nErrorLevel = DoCommand(first, param, NULL); } - nErrorLevel = DoCommand(first, param, NULL); + FreeCommand(Cmd); return nErrorLevel; } diff --git a/base/shell/cmd/cmd.h b/base/shell/cmd/cmd.h index bb2221fb23d..0812596719b 100644 --- a/base/shell/cmd/cmd.h +++ b/base/shell/cmd/cmd.h @@ -424,6 +424,9 @@ VOID ParseErrorEx(IN PCTSTR s); extern BOOL bParseError; extern TCHAR ParseLine[CMDLINE_LENGTH]; +extern BOOL bIgnoreParserComments; +extern BOOL bHandleContinuations; + /* Prototypes from PATH.C */ INT cmd_path (LPTSTR); diff --git a/base/shell/cmd/goto.c b/base/shell/cmd/goto.c index 5174b62417f..777963344a2 100644 --- a/base/shell/cmd/goto.c +++ b/base/shell/cmd/goto.c @@ -41,7 +41,24 @@ INT cmd_goto(LPTSTR param) TRACE("cmd_goto(\'%s\')\n", debugstr_aw(param)); - if (!_tcsncmp(param, _T("/?"), 2)) + /* + * Keep the help message handling here too. + * This allows us to reproduce the Windows' CMD "bug" + * (from a batch script): + * + * SET label=/? + * CALL :%%label%% + * + * calls GOTO help, due to how CALL :label functionality + * is internally implemented. + * + * See https://stackoverflow.com/q/31987023/13530036 + * for more details. + * + * Note that the choice of help parsing forbids + * any label containing '/?' in it. + */ + if (_tcsstr(param, _T("/?"))) { ConOutResPaging(TRUE, STRING_GOTO_HELP1); return 0; diff --git a/base/shell/cmd/parser.c b/base/shell/cmd/parser.c index 1680c5be4d8..29797bf36ae 100644 --- a/base/shell/cmd/parser.c +++ b/base/shell/cmd/parser.c @@ -93,6 +93,9 @@ static BOOL bLineContinuations; static PTCHAR ParsePos; static PTCHAR OldParsePos; +BOOL bIgnoreParserComments = TRUE; +BOOL bHandleContinuations = TRUE; + static TCHAR CurrentToken[CMDLINE_LENGTH]; static TOK_TYPE CurrentTokenType = TOK_END; #ifndef MSCMD_PARSER_BUGS @@ -206,6 +209,9 @@ restart: if (!ReadLine(ParseLine, TRUE)) { /* ^C pressed, or line was too long */ + // + // FIXME: Distinguish with respect to BATCH end of file !! + // bParseError = TRUE; } else @@ -445,7 +451,7 @@ ParseToken( IN TCHAR ExtraEnd OPTIONAL, IN PCTSTR Separators OPTIONAL) { - return ParseTokenEx(0, ExtraEnd, Separators, TRUE); + return ParseTokenEx(0, ExtraEnd, Separators, bHandleContinuations); } @@ -1294,7 +1300,7 @@ ParseCommandBinaryOp( if (OpType == C_OP_LOWEST) // i.e. CP_MULTI { /* Ignore any parser-level comments */ - if (*CurrentToken == _T(':')) + if (bIgnoreParserComments && (*CurrentToken == _T(':'))) { /* Ignore the rest of the line, including line continuations */ while (ParseToken(0, NULL) != TOK_END) @@ -1445,7 +1451,7 @@ ParseCommandOp( /* Parse the prefix "quiet" operator '@' as a separate command. * Thus, @@foo@bar is parsed as: '@', '@', 'foo@bar'. */ - ParseTokenEx(_T('@'), _T('('), STANDARD_SEPS, TRUE); + ParseTokenEx(_T('@'), _T('('), STANDARD_SEPS, bHandleContinuations); return ParseCommandBinaryOp(OpType); }