001/*
002 * (C) Copyright 2006-2016 Nuxeo SA (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 *     bstefanescu
018 *     jcarsique
019 *     Yannis JULIENNE
020 */
021package org.nuxeo.connect.update.task.standalone.commands;
022
023import static java.nio.charset.StandardCharsets.UTF_8;
024
025import java.io.File;
026import java.io.IOException;
027import java.io.RandomAccessFile;
028import java.util.Map;
029
030import org.apache.commons.io.FileUtils;
031import org.apache.commons.logging.Log;
032import org.apache.commons.logging.LogFactory;
033import org.nuxeo.common.Environment;
034import org.nuxeo.common.utils.FileMatcher;
035import org.nuxeo.common.utils.FileRef;
036import org.nuxeo.common.utils.FileVersion;
037import org.nuxeo.connect.update.PackageException;
038import org.nuxeo.connect.update.ValidationStatus;
039import org.nuxeo.connect.update.task.Command;
040import org.nuxeo.connect.update.task.Task;
041import org.nuxeo.connect.update.task.standalone.UninstallTask;
042import org.nuxeo.connect.update.util.IOUtils;
043import org.nuxeo.connect.update.xml.XmlWriter;
044import org.w3c.dom.Element;
045
046/**
047 * Copy a file to the given target directory or file. If the target is a directory the file name is preserved. If the
048 * target file exists it will be replaced if overwrite is true otherwise the command validation fails. If the source
049 * file is a directory, then the files it contents will be recursively copied.
050 * <p>
051 * If md5 is set then the copy command will be validated only if the target file has the same md5 as the one specified
052 * in the command.
053 * <p>
054 * The Copy command has as inverse either Delete either another Copy command. If the file was copied without overwriting
055 * then Delete is the inverse (with a md5 set to the one of the copied file). If the file was overwritten then the
056 * inverse of Copy command is another copy command with the md5 to the one of the copied file and the overwrite flag to
057 * true. The file to copy will be the backup of the overwritten file.
058 *
059 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
060 */
061public class Copy extends AbstractCommand {
062
063    protected static final Log log = LogFactory.getLog(Copy.class);
064
065    public static final String ID = "copy";
066
067    protected static final String LAUNCHER_JAR = "nuxeo-launcher.jar";
068
069    protected static final String LAUNCHER_CHANGED_PROPERTY = "launcher.changed";
070
071    /**
072     * The source file. It can be a file or a directory.
073     */
074    protected File file;
075
076    /**
077     * The target file. It can be a directory since 5.5
078     */
079    protected File tofile;
080
081    protected boolean overwrite;
082
083    protected String md5;
084
085    protected boolean removeOnExit;
086
087    /**
088     * @since 5.5
089     */
090    protected boolean append;
091
092    /**
093     * @since 5.5
094     */
095    private boolean overwriteIfNewerVersion;
096
097    /**
098     * @since 5.5
099     */
100    private boolean upgradeOnly;
101
102    protected Copy(String id) {
103        super(id);
104    }
105
106    public Copy() {
107        this(ID);
108    }
109
110    public Copy(File file, File tofile, String md5, boolean overwrite) {
111        this(ID, file, tofile, md5, overwrite, false);
112    }
113
114    public Copy(File file, File tofile, String md5, boolean overwrite, boolean removeOnExit) {
115        this(ID, file, tofile, md5, overwrite, removeOnExit);
116    }
117
118    protected Copy(String id, File file, File tofile, String md5, boolean overwrite, boolean removeOnExit) {
119        this(id);
120        this.file = file;
121        this.tofile = tofile;
122        this.md5 = md5;
123        this.overwrite = overwrite;
124        this.removeOnExit = removeOnExit;
125    }
126
127    @Override
128    protected Command doRun(Task task, Map<String, String> prefs) throws PackageException {
129        if (!file.exists()) {
130            log.warn("Can't copy " + file + " . File missing.");
131            return null;
132        }
133        return doCopy(task, prefs, file, tofile, overwrite);
134    }
135
136    /**
137     * @since 5.5
138     */
139    protected Command doCopy(Task task, Map<String, String> prefs, File fileToCopy, File dst, boolean doOverwrite)
140            throws PackageException {
141        String dstmd5;
142        File bak = null;
143        CompositeCommand rollbackCommand = new CompositeCommand();
144        if (fileToCopy.isDirectory()) {
145            if (fileToCopy != file) {
146                dst = new File(dst, fileToCopy.getName());
147            }
148            dst.mkdirs();
149            for (File childFile : fileToCopy.listFiles()) {
150                rollbackCommand.addCommand(doCopy(task, prefs, childFile, dst, doOverwrite));
151            }
152            return rollbackCommand;
153        }
154        if (dst.isDirectory()) {
155            dst = new File(dst, fileToCopy.getName());
156        }
157        try {
158            FileMatcher filenameMatcher = FileMatcher.getMatcher("{n:.*-}[0-9]+.*\\.jar");
159            boolean isVersionnedJarFile = filenameMatcher.match(fileToCopy.getName());
160            if (isVersionnedJarFile) {
161                log.warn(String.format(
162                        "Use of the <copy /> command on JAR files is not recommended, prefer using <update /> command to ensure a safe rollback. (%s)",
163                        fileToCopy.getName()));
164            }
165            if (isVersionnedJarFile && (overwriteIfNewerVersion || upgradeOnly)) {
166                // Compare source and destination versions set in filename
167                FileVersion fileToCopyVersion, dstVersion = null;
168                String filenameWithoutVersion = filenameMatcher.getValue();
169                FileMatcher versionMatcher = FileMatcher.getMatcher(filenameWithoutVersion + "{v:[0-9]+.*}\\.jar");
170                // Get new file version
171                if (versionMatcher.match(fileToCopy.getName())) {
172                    fileToCopyVersion = new FileVersion(versionMatcher.getValue());
173                    // Get original file name and version
174                    File dir = dst.getParentFile();
175                    File[] list = dir.listFiles();
176                    if (list != null) {
177                        for (File f : list) {
178                            if (versionMatcher.match(f.getName())) {
179                                dst = f;
180                                dstVersion = new FileVersion(versionMatcher.getValue());
181                                break;
182                            }
183                        }
184                    }
185                    if (dstVersion == null) {
186                        if (upgradeOnly) {
187                            return null;
188                        }
189                    } else if (fileToCopyVersion.greaterThan(dstVersion)) {
190                        // backup dst and generate rollback command
191                        File oldDst = dst;
192                        dst = new File(dst.getParentFile(), fileToCopy.getName());
193                        File backup = IOUtils.backup(task.getPackage(), oldDst);
194                        rollbackCommand.addCommand(new Copy(backup, oldDst, null, false));
195                        // Delete old dst as its name differs from new version
196                        oldDst.delete();
197                    } else if (fileToCopyVersion.isSnapshot() && fileToCopyVersion.equals(dstVersion)) {
198                        doOverwrite = true;
199                    } else if (!doOverwrite) {
200                        log.info("Ignore " + fileToCopy + " because not newer than " + dstVersion
201                                + " and 'overwrite' is set to false.");
202                        return null;
203                    }
204                }
205            }
206            if (dst.exists()) { // backup the destination file if exist.
207                if (!doOverwrite && !append) { // force a rollback
208                    throw new PackageException(
209                            "Copy command has overwrite flag on false but destination file exists: " + dst);
210                }
211                if (task instanceof UninstallTask) {
212                    // no backup for uninstall task
213                } else if (append) {
214                    bak = IOUtils.backup(task.getPackage(), fileToCopy);
215                } else {
216                    bak = IOUtils.backup(task.getPackage(), dst);
217                }
218            } else { // target file doesn't exists - it will be created
219                dst.getParentFile().mkdirs();
220            }
221
222            // copy the file - use getContentToCopy to allow parameterization
223            // for subclasses
224            String content = getContentToCopy(fileToCopy, prefs);
225            if (content != null) {
226                if (append && dst.exists()) {
227                    try (RandomAccessFile rfile = new RandomAccessFile(dst, "r")) {
228                        rfile.seek(dst.length());
229                        if (!"".equals(rfile.readLine())) {
230                            content = System.getProperty("line.separator") + content;
231                        }
232                    } catch (IOException e) {
233                        log.error(e);
234                    }
235                }
236                FileUtils.writeStringToFile(dst, content, UTF_8, append);
237            } else {
238                File tmp = new File(dst.getPath() + ".tmp");
239                org.nuxeo.common.utils.FileUtils.copy(fileToCopy, tmp);
240                if (!tmp.renameTo(dst)) {
241                    tmp.delete();
242                    org.nuxeo.common.utils.FileUtils.copy(fileToCopy, dst);
243                }
244            }
245            // check whether the copied or restored file was the launcher
246            if (dst.getName().equals(LAUNCHER_JAR) || fileToCopy.getName().equals(LAUNCHER_JAR)) {
247                Environment env = Environment.getDefault();
248                env.setProperty(LAUNCHER_CHANGED_PROPERTY, "true");
249            }
250            // get the md5 of the copied file.
251            dstmd5 = IOUtils.createMd5(dst);
252        } catch (IOException e) {
253            throw new PackageException("Failed to copy " + fileToCopy, e);
254        }
255        if (bak == null) { // no file was replaced
256            rollbackCommand.addCommand(new Delete(dst, dstmd5, removeOnExit));
257        } else if (append) {
258            rollbackCommand.addCommand(new UnAppend(bak, dst));
259        } else {
260            rollbackCommand.addCommand(new Copy(bak, dst, dstmd5, true));
261        }
262        return rollbackCommand;
263    }
264
265    /**
266     * Override in subclass to parameterize content.
267     *
268     * @since 5.5
269     * @return Content to put in destination file. See {@link #append} parameter to determine if returned content is
270     *         replacing or appending to destination file.
271     */
272    protected String getContentToCopy(File fileToCopy, Map<String, String> prefs) throws PackageException {
273        // For compliance
274        String deprecatedContent = getContentToCopy(prefs);
275        if (deprecatedContent != null) {
276            return deprecatedContent;
277        }
278        if (append) {
279            try {
280                return FileUtils.readFileToString(fileToCopy, UTF_8);
281            } catch (IOException e) {
282                throw new PackageException("Couldn't read " + fileToCopy.getName(), e);
283            }
284        } else {
285            return null;
286        }
287    }
288
289    /**
290     * @deprecated Since 5.5, use {@link #getContentToCopy(File, Map)}. This method is missing the fileToCopy reference.
291     *             Using {@link #file} is leading to errors.
292     */
293    @Deprecated
294    protected String getContentToCopy(Map<String, String> prefs) throws PackageException {
295        return null;
296    }
297
298    @Override
299    protected void doValidate(Task task, ValidationStatus status) throws PackageException {
300        if (file == null || tofile == null) {
301            status.addError("Cannot execute command in installer."
302                    + " Invalid copy syntax: file, dir, tofile or todir was not specified.");
303            return;
304        }
305        if (tofile.isFile() && !overwrite && !append) {
306            if (removeOnExit) {
307                // a plugin is still there due to a previous action that needs a
308                // restart
309                status.addError("A restart is needed to perform this operation: cleaning " + tofile.getName());
310            } else {
311                status.addError("Cannot overwrite existing file: " + tofile.getName());
312            }
313        }
314        if (md5 != null) {
315            try {
316                if (tofile.isFile() && !md5.equals(IOUtils.createMd5(tofile))) {
317                    status.addError("MD5 check failed. File: " + tofile + " has changed since its backup");
318                }
319            } catch (IOException e) {
320                throw new PackageException(e);
321            }
322        }
323    }
324
325    @Override
326    public void readFrom(Element element) throws PackageException {
327        boolean sourceIsDir = false;
328        File dir = null;
329        String v = element.getAttribute("dir");
330        if (v.length() > 0) {
331            dir = new File(v);
332        }
333        v = element.getAttribute("file");
334        if (v.length() > 0) {
335            if (dir != null) {
336                file = new File(dir, v);
337            } else {
338                file = new File(v);
339            }
340            guardVars.put("file", file);
341        } else {
342            sourceIsDir = true;
343            file = dir;
344            guardVars.put("dir", dir);
345        }
346
347        v = element.getAttribute("todir");
348        if (v.length() > 0) {
349            if (sourceIsDir) {
350                tofile = new File(v);
351                guardVars.put("todir", tofile);
352            } else {
353                tofile = new File(v, file.getName());
354                guardVars.put("tofile", tofile);
355            }
356        } else {
357            v = element.getAttribute("tofile");
358            if (v.length() > 0) {
359                FileRef ref = FileRef.newFileRef(v);
360                tofile = ref.getFile();
361                guardVars.put("tofile", tofile);
362                ref.fillPatternVariables(guardVars);
363            }
364        }
365
366        v = element.getAttribute("md5");
367        if (v.length() > 0) {
368            md5 = v;
369        }
370        v = element.getAttribute("overwrite");
371        if (v.length() > 0) {
372            overwrite = Boolean.parseBoolean(v);
373        }
374        v = element.getAttribute("removeOnExit");
375        if (v.length() > 0) {
376            removeOnExit = Boolean.parseBoolean(v);
377        }
378        v = element.getAttribute("overwriteIfNewerVersion");
379        if (v.length() > 0) {
380            overwriteIfNewerVersion = Boolean.parseBoolean(v);
381        }
382        v = element.getAttribute("upgradeOnly");
383        if (v.length() > 0) {
384            upgradeOnly = Boolean.parseBoolean(v);
385        }
386        v = element.getAttribute("append");
387        if (v.length() > 0) {
388            append = Boolean.parseBoolean(v);
389        }
390    }
391
392    @Override
393    public void writeTo(XmlWriter writer) {
394        writer.start(ID);
395        if (file != null) {
396            writer.attr("file", file.getAbsolutePath());
397        }
398        if (tofile != null) {
399            writer.attr("tofile", tofile.getAbsolutePath());
400        }
401        writer.attr("overwrite", String.valueOf(overwrite));
402        if (md5 != null) {
403            writer.attr("md5", md5);
404        }
405        if (removeOnExit) {
406            writer.attr("removeOnExit", "true");
407        }
408        if (overwriteIfNewerVersion) {
409            writer.attr("overwriteIfNewerVersion", "true");
410        }
411        if (upgradeOnly) {
412            writer.attr("upgradeOnly", "true");
413        }
414        if (append) {
415            writer.attr("append", "true");
416        }
417        writer.end();
418    }
419
420}