001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019
020package org.apache.commons.exec;
021
022import java.io.File;
023import java.nio.file.Path;
024import java.util.ArrayList;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028import java.util.Objects;
029import java.util.StringTokenizer;
030
031import org.apache.commons.exec.util.StringUtils;
032
033/**
034 * CommandLine objects help handling command lines specifying processes to execute. The class can be used to a command line by an application.
035 */
036public class CommandLine {
037
038    /**
039     * Encapsulates a command line argument.
040     */
041    static final class Argument {
042
043        /**
044         * Argument value.
045         */
046        private final String value;
047
048        /**
049         * Whether to quote arguments.
050         */
051        private final boolean handleQuoting;
052
053        private Argument(final String value, final boolean handleQuoting) {
054            this.value = value.trim();
055            this.handleQuoting = handleQuoting;
056        }
057
058        private String getValue() {
059            return value;
060        }
061
062        private boolean isHandleQuoting() {
063            return handleQuoting;
064        }
065    }
066
067    /**
068     * Create a command line from a string.
069     *
070     * @param line the first element becomes the executable, the rest the arguments.
071     * @return the parsed command line.
072     * @throws IllegalArgumentException If line is null or all whitespace.
073     */
074    public static CommandLine parse(final String line) {
075        return parse(line, null);
076    }
077
078    /**
079     * Create a command line from a string.
080     *
081     * @param line            the first element becomes the executable, the rest the arguments.
082     * @param substitutionMap the name/value pairs used for substitution.
083     * @return the parsed command line.
084     * @throws IllegalArgumentException If line is null or all whitespace.
085     */
086    public static CommandLine parse(final String line, final Map<String, ?> substitutionMap) {
087        if (line == null) {
088            throw new IllegalArgumentException("Command line cannot be null");
089        }
090        if (line.trim().isEmpty()) {
091            throw new IllegalArgumentException("Command line cannot be empty");
092        }
093        final String[] tmp = translateCommandline(line);
094        final CommandLine cl = new CommandLine(tmp[0]);
095        cl.setSubstitutionMap(substitutionMap);
096        for (int i = 1; i < tmp.length; i++) {
097            cl.addArgument(tmp[i]);
098        }
099        return cl;
100    }
101
102    /**
103     * Crack a command line.
104     *
105     * @param toProcess the command line to process.
106     * @return the command line broken into strings. An empty or null toProcess parameter results in a zero sized array.
107     */
108    private static String[] translateCommandline(final String toProcess) {
109        if (toProcess == null || toProcess.trim().isEmpty()) {
110            // no command? no string
111            return new String[0];
112        }
113        // parse with a simple finite state machine.
114        final int normal = 0;
115        final int inQuote = 1;
116        final int inDoubleQuote = 2;
117        int state = normal;
118        final StringTokenizer tok = new StringTokenizer(toProcess, "\"\' ", true);
119        final ArrayList<String> list = new ArrayList<>();
120        StringBuilder current = new StringBuilder();
121        boolean lastTokenHasBeenQuoted = false;
122        while (tok.hasMoreTokens()) {
123            final String nextTok = tok.nextToken();
124            switch (state) {
125            case inQuote:
126                if ("\'".equals(nextTok)) {
127                    lastTokenHasBeenQuoted = true;
128                    state = normal;
129                } else {
130                    current.append(nextTok);
131                }
132                break;
133            case inDoubleQuote:
134                if ("\"".equals(nextTok)) {
135                    lastTokenHasBeenQuoted = true;
136                    state = normal;
137                } else {
138                    current.append(nextTok);
139                }
140                break;
141            default:
142                switch (nextTok) {
143                case "\'":
144                    state = inQuote;
145                    break;
146                case "\"":
147                    state = inDoubleQuote;
148                    break;
149                case " ":
150                    if (lastTokenHasBeenQuoted || current.length() != 0) {
151                        list.add(current.toString());
152                        current = new StringBuilder();
153                    }
154                    break;
155                default:
156                    current.append(nextTok);
157                    break;
158                }
159                lastTokenHasBeenQuoted = false;
160                break;
161            }
162        }
163        if (lastTokenHasBeenQuoted || current.length() != 0) {
164            list.add(current.toString());
165        }
166        if (state == inQuote || state == inDoubleQuote) {
167            throw new IllegalArgumentException("Unbalanced quotes in " + toProcess);
168        }
169        final String[] args = new String[list.size()];
170        return list.toArray(args);
171    }
172
173    /**
174     * The arguments of the command.
175     */
176    private final List<Argument> arguments = new ArrayList<>();
177
178    /**
179     * The program to execute.
180     */
181    private final String executable;
182
183    /**
184     * A map of name value pairs used to expand command line arguments.
185     */
186    private Map<String, ?> substitutionMap; // This can contain values other than Strings.
187
188    /**
189     * Tests whether a file was used to set the executable.
190     */
191    private final boolean isFile;
192
193    /**
194     * Copy constructor.
195     *
196     * @param other the instance to copy.
197     */
198    public CommandLine(final CommandLine other) {
199        this.executable = other.getExecutable();
200        this.isFile = other.isFile();
201        this.arguments.addAll(other.arguments);
202        if (other.getSubstitutionMap() != null) {
203            this.substitutionMap = new HashMap<>(other.getSubstitutionMap());
204        }
205    }
206
207    /**
208     * Constructs a command line without any arguments.
209     *
210     * @param executable the executable file.
211     */
212    public CommandLine(final File executable) {
213        this.isFile = true;
214        this.executable = toCleanExecutable(executable.getAbsolutePath());
215    }
216
217    /**
218     * Constructs a command line without any arguments.
219     *
220     * @param executable the executable file.
221     * @since 1.5.0
222     */
223    public CommandLine(final Path executable) {
224        this.isFile = true;
225        this.executable = toCleanExecutable(executable.toAbsolutePath().toString());
226    }
227
228    /**
229     * Constructs a command line without any arguments.
230     *
231     * @param executable the executable.
232     * @throws NullPointerException     on null input.
233     * @throws IllegalArgumentException on empty input.
234     */
235    public CommandLine(final String executable) {
236        this.isFile = false;
237        this.executable = toCleanExecutable(executable);
238    }
239
240    /**
241     * Add a single argument. Handles quoting.
242     *
243     * @param argument The argument to add.
244     * @return The command line itself.
245     * @throws IllegalArgumentException If argument contains both single and double quotes.
246     */
247    public CommandLine addArgument(final String argument) {
248        return addArgument(argument, true);
249    }
250
251    /**
252     * Add a single argument.
253     *
254     * @param argument      The argument to add.
255     * @param handleQuoting Add the argument with/without handling quoting.
256     * @return The command line itself.
257     */
258    public CommandLine addArgument(final String argument, final boolean handleQuoting) {
259        if (argument == null) {
260            return this;
261        }
262        // check if we can really quote the argument - if not throw an
263        // IllegalArgumentException
264        if (handleQuoting) {
265            StringUtils.quoteArgument(argument);
266        }
267        arguments.add(new Argument(argument, handleQuoting));
268        return this;
269    }
270
271    /**
272     * Add multiple arguments. Handles parsing of quotes and whitespace. Please note that the parsing can have undesired side-effects therefore it is
273     * recommended to build the command line incrementally.
274     *
275     * @param addArguments A string containing multiple arguments.
276     * @return The command line itself.
277     */
278    public CommandLine addArguments(final String addArguments) {
279        return addArguments(addArguments, true);
280    }
281
282    /**
283     * Add multiple arguments. Handles parsing of quotes and whitespace. Please note that the parsing can have undesired side-effects therefore it is
284     * recommended to build the command line incrementally.
285     *
286     * @param addArguments  A string containing multiple arguments.
287     * @param handleQuoting Add the argument with/without handling quoting.
288     * @return The command line itself.
289     */
290    public CommandLine addArguments(final String addArguments, final boolean handleQuoting) {
291        if (addArguments != null) {
292            final String[] argumentsArray = translateCommandline(addArguments);
293            addArguments(argumentsArray, handleQuoting);
294        }
295        return this;
296    }
297
298    /**
299     * Add multiple arguments. Handles parsing of quotes and whitespace.
300     *
301     * @param addArguments An array of arguments.
302     * @return The command line itself.
303     */
304    public CommandLine addArguments(final String[] addArguments) {
305        return addArguments(addArguments, true);
306    }
307
308    /**
309     * Add multiple arguments.
310     *
311     * @param addArguments  An array of arguments.
312     * @param handleQuoting Add the argument with/without handling quoting.
313     * @return The command line itself.
314     */
315    public CommandLine addArguments(final String[] addArguments, final boolean handleQuoting) {
316        if (addArguments != null) {
317            for (final String addArgument : addArguments) {
318                addArgument(addArgument, handleQuoting);
319            }
320        }
321        return this;
322    }
323
324    /**
325     * Expand variables in a command line argument.
326     *
327     * @param argument the argument.
328     * @return the expanded string.
329     */
330    private String expandArgument(final String argument) {
331        final StringBuffer stringBuffer = StringUtils.stringSubstitution(argument, getSubstitutionMap(), true);
332        return stringBuffer.toString();
333    }
334
335    /**
336     * Gets the expanded and quoted command line arguments.
337     *
338     * @return The quoted arguments.
339     */
340    public String[] getArguments() {
341        Argument currArgument;
342        String expandedArgument;
343        final String[] result = new String[arguments.size()];
344        for (int i = 0; i < result.length; i++) {
345            currArgument = arguments.get(i);
346            expandedArgument = expandArgument(currArgument.getValue());
347            result[i] = currArgument.isHandleQuoting() ? StringUtils.quoteArgument(expandedArgument) : expandedArgument;
348        }
349        return result;
350    }
351
352    /**
353     * Gets the executable.
354     *
355     * @return The executable.
356     */
357    public String getExecutable() {
358        // Expand the executable and replace '/' and '\\' with the platform
359        // specific file separator char. This is safe here since we know
360        // that this is a platform specific command.
361        return StringUtils.fixFileSeparatorChar(expandArgument(executable));
362    }
363
364    /**
365     * Gets the substitution map.
366     *
367     * @return the substitution map.
368     */
369    public Map<String, ?> getSubstitutionMap() {
370        return substitutionMap;
371    }
372
373    /**
374     * Tests whether a file was used to set the executable.
375     *
376     * @return true whether a file was used for setting the executable.
377     */
378    public boolean isFile() {
379        return isFile;
380    }
381
382    /**
383     * Sets the substitutionMap to expand variables in the command line.
384     *
385     * @param substitutionMap the map
386     */
387    public void setSubstitutionMap(final Map<String, ?> substitutionMap) {
388        this.substitutionMap = substitutionMap;
389    }
390
391    /**
392     * Cleans the executable string. The argument is trimmed and '/' and '\\' are replaced with the platform specific file separator char
393     *
394     * @param dirtyExecutable the executable.
395     * @return the platform-specific executable string.
396     * @throws NullPointerException     on null input.
397     * @throws IllegalArgumentException on empty input.
398     */
399    private String toCleanExecutable(final String dirtyExecutable) {
400        Objects.requireNonNull(dirtyExecutable, "dirtyExecutable");
401        if (dirtyExecutable.trim().isEmpty()) {
402            throw new IllegalArgumentException("Executable cannot be empty");
403        }
404        return StringUtils.fixFileSeparatorChar(dirtyExecutable);
405    }
406
407    /**
408     * Stringify operator returns the command line as a string.
409     * Parameters are correctly quoted when containing a space or left untouched if they are already
410     * quoted.
411     *
412     * @return the command line as single string.
413     */
414    @Override
415    public String toString() {
416        return "[" + String.join(", ", toStrings()) + "]";
417    }
418
419    /**
420     * Converts the command line as an array of strings.
421     *
422     * @return The command line as a string array.
423     */
424    public String[] toStrings() {
425        final String[] result = new String[arguments.size() + 1];
426        result[0] = getExecutable();
427        System.arraycopy(getArguments(), 0, result, 1, result.length - 1);
428        return result;
429    }
430}