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