001/*
002 * (C) Copyright 2006-2019 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Thierry Delprat
018 *     Julien Carsique
019 *     Florent Guillaume
020 */
021package org.nuxeo.ecm.platform.commandline.executor.service.executors;
022
023import static org.apache.commons.io.IOUtils.buffer;
024
025import java.io.File;
026import java.io.IOException;
027import java.io.InputStream;
028import java.io.OutputStream;
029import java.nio.charset.Charset;
030import java.nio.file.Files;
031import java.nio.file.InvalidPathException;
032import java.nio.file.Path;
033import java.nio.file.Paths;
034import java.util.ArrayList;
035import java.util.Arrays;
036import java.util.Collections;
037import java.util.Iterator;
038import java.util.LinkedList;
039import java.util.List;
040import java.util.Map.Entry;
041import java.util.concurrent.atomic.AtomicInteger;
042import java.util.regex.Matcher;
043import java.util.regex.Pattern;
044import java.util.stream.Collectors;
045
046import org.apache.commons.io.IOUtils;
047import org.apache.commons.lang3.SystemUtils;
048import org.apache.logging.log4j.LogManager;
049import org.apache.logging.log4j.Logger;
050import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
051import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters.ParameterValue;
052import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
053import org.nuxeo.ecm.platform.commandline.executor.service.CommandLineDescriptor;
054import org.nuxeo.ecm.platform.commandline.executor.service.EnvironmentDescriptor;
055import org.nuxeo.runtime.RuntimeServiceException;
056
057/**
058 * Default implementation of the {@link Executor} interface. Use simple shell exec.
059 */
060public class ShellExecutor implements Executor {
061
062    private static final Logger log = LogManager.getLogger(ShellExecutor.class);
063
064    protected static final AtomicInteger PIPE_COUNT = new AtomicInteger();
065
066    /** Used to split the contributed command, NOT the passed parameter values. */
067    protected static final Pattern COMMAND_SPLIT = Pattern.compile("\"([^\"]*)\"|'([^']*)'|[^\\s]+");
068
069    @Override
070    public ExecResult exec(CommandLineDescriptor cmdDesc, CmdParameters params, EnvironmentDescriptor env) {
071        String commandLine = cmdDesc.getCommand() + " " + String.join(" ", cmdDesc.getParametersString());
072        try {
073            log.debug("Running system command: {} with parameters: {}", () -> commandLine,
074                    () -> params.getParameters()
075                                .entrySet()
076                                .stream()
077                                .map(e -> String.format("%s=%s", e.getKey(), e.getValue().getValue()))
078                                .collect(Collectors.joining(", ")));
079            long t0 = System.currentTimeMillis();
080            ExecResult res = exec1(cmdDesc, params, env);
081            long t1 = System.currentTimeMillis();
082            return new ExecResult(commandLine, res.getOutput(), t1 - t0, res.getReturnCode());
083        } catch (IOException e) {
084            return new ExecResult(commandLine, e);
085        }
086    }
087
088    protected ExecResult exec1(CommandLineDescriptor cmdDesc, CmdParameters params, EnvironmentDescriptor env)
089            throws IOException {
090        // split the configured parameters while keeping quoted parts intact
091        List<String> list = new ArrayList<>();
092        list.add(cmdDesc.getCommand());
093        Matcher m = COMMAND_SPLIT.matcher(cmdDesc.getParametersString());
094        while (m.find()) {
095            String word;
096            if (m.group(1) != null) {
097                word = m.group(1); // double-quoted
098            } else if (m.group(2) != null) {
099                word = m.group(2); // single-quoted
100            } else {
101                word = m.group(); // word
102            }
103            List<String> words = replaceParams(word, params);
104            list.addAll(words);
105        }
106
107        List<ProcessBuilder> builders = new LinkedList<>();
108        List<String> command = new LinkedList<>();
109        for (Iterator<String> it = list.iterator(); it.hasNext();) {
110            String word = it.next();
111            boolean build;
112            if (word.equals("|")) {
113                build = true;
114            } else {
115                // on Windows, look up the command in the PATH first
116                if (command.isEmpty() && SystemUtils.IS_OS_WINDOWS) {
117                    command.add(getCommandAbsolutePath(word));
118                } else {
119                    command.add(word);
120                }
121                build = !it.hasNext();
122            }
123            if (!build) {
124                continue;
125            }
126            var processBuilder = createProcessBuilder(command, env);
127            builders.add(processBuilder);
128
129            command = new LinkedList<>(); // reset for next loop
130        }
131        // now start all process
132        List<Process> processes = ProcessBuilder.startPipeline(builders);
133
134        // get result from last process
135        List<String> output;
136        Process last = processes.get(processes.size() - 1);
137        try (var stream = buffer(last.getInputStream())) {
138            output = IOUtils.readLines(stream, Charset.defaultCharset()); // use the host charset
139        }
140
141        // get return code from processes
142        int returnCode = getReturnCode(processes);
143
144        return new ExecResult(null, output, 0, returnCode);
145    }
146
147    protected ProcessBuilder createProcessBuilder(List<String> command, EnvironmentDescriptor env) {
148        ProcessBuilder processBuilder = new ProcessBuilder(command);
149        log.debug("Building Process for command: {}", () -> String.join(" ", processBuilder.command()));
150        processBuilder.directory(new File(env.getWorkingDirectory()));
151        processBuilder.environment().putAll(env.getParameters());
152        processBuilder.redirectErrorStream(true);
153        return processBuilder;
154    }
155
156    protected int getReturnCode(List<Process> processes) {
157        // wait for all processes, get first non-0 exit status
158        int returnCode = 0;
159        for (Process p : processes) {
160            try {
161                int exitCode = p.waitFor();
162                if (returnCode == 0) {
163                    returnCode = exitCode;
164                }
165            } catch (InterruptedException e) {
166                Thread.currentThread().interrupt();
167                throw new RuntimeServiceException(e);
168            }
169        }
170        return returnCode;
171    }
172
173    /**
174     * Returns a started daemon thread piping bytes from the InputStream to the OutputStream.
175     * <p>
176     * The streams are both closed when the copy is finished.
177     *
178     * @since 7.10
179     * @deprecated since 11.1, seems unused
180     */
181    @Deprecated(since = "11.1")
182    public static Thread pipe(InputStream in, OutputStream out) {
183        Runnable run = () -> {
184            try (in; out) {
185                IOUtils.copy(in, out);
186                out.flush();
187            } catch (IOException e) {
188                throw new RuntimeServiceException(e);
189            }
190        };
191        Thread thread = new Thread(run, "Nuxeo-pipe-" + PIPE_COUNT.incrementAndGet());
192        thread.setDaemon(true);
193        thread.start();
194        return thread;
195    }
196
197    /**
198     * Expands parameter strings in a parameter word.
199     * <p>
200     * This may return several words if the parameter value is marked as a list.
201     *
202     * @since 7.10
203     */
204    public static List<String> replaceParams(String word, CmdParameters params) {
205        for (Entry<String, ParameterValue> es : params.getParameters().entrySet()) {
206            String name = es.getKey();
207            ParameterValue paramVal = es.getValue();
208            String param = "#{" + name + "}";
209            if (paramVal.isMulti()) {
210                if (word.equals(param)) {
211                    return paramVal.getValues();
212                }
213            } else if (word.contains(param)) {
214                word = word.replace(param, paramVal.getValue());
215            }
216
217        }
218        return Collections.singletonList(word);
219    }
220
221    /**
222     * Returns the absolute path of a command looked up on the PATH or the initial string if not found.
223     *
224     * @since 7.10
225     */
226    public static String getCommandAbsolutePath(String command) {
227        // no lookup if the command is already an absolute path
228        if (Paths.get(command).isAbsolute()) {
229            return command;
230        }
231        List<String> extensions = Arrays.asList("", ".exe");
232        // lookup for "command" or "command.exe" in the PATH
233        String[] systemPaths = System.getenv("PATH").split(File.pathSeparator);
234        for (String ext : extensions) {
235            for (String sp : systemPaths) {
236                String fullCommand = command + ext;
237                try {
238                    Path path = Paths.get(sp.trim());
239                    if (Files.exists(path.resolve(fullCommand))) {
240                        return path.resolve(fullCommand).toString();
241                    }
242                } catch (InvalidPathException e) {
243                    log.warn("PATH environment variable contains an invalid path: {}", fullCommand, e);
244                }
245            }
246        }
247        // not found : return the initial string
248        return command;
249    }
250
251}