blob: 54d6811baef0a141098227967ffd788e43c42787 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/*
* Since the initial version of this file was developed on the clock on
* an NSF grant I should say the following boilerplate:
*
* This material is based upon work supported by the National Science
* Foundation under Grant No. EIA-0196404. Any opinions, findings, and
* conclusions or recommendations expressed in this material are those
* of the author and do not necessarily reflect the views of the
* National Science Foundation.
*/
package org.apache.tools.ant.taskdefs.optional.unix;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Stream;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.dispatch.DispatchTask;
import org.apache.tools.ant.dispatch.DispatchUtils;
import org.apache.tools.ant.taskdefs.LogOutputStream;
import org.apache.tools.ant.types.FileSet;
/**
* Creates, Deletes, Records and Restores Symlinks.
*
* <p>This task performs several related operations. In the most trivial
* and default usage, it creates a link specified in the link attribute to
* a resource specified in the resource attribute. The second usage of this
* task is to traverse a directory structure specified by a fileset,
* and write a properties file in each included directory describing the
* links found in that directory. The third usage is to traverse a
* directory structure specified by a fileset, looking for properties files
* (also specified as included in the fileset) and recreate the links
* that have been previously recorded for each directory. Finally, it can be
* used to remove a symlink without deleting the associated resource.</p>
*
* <p>Usage examples:</p>
*
* <p>Make a link named &quot;foo&quot; to a resource named
* &quot;bar.foo&quot; in subdir:</p>
* <pre>
* &lt;symlink link=&quot;${dir.top}/foo&quot; resource=&quot;${dir.top}/subdir/bar.foo&quot;/&gt;
* </pre>
*
* <p>Record all links in subdir and its descendants in files named
* &quot;dir.links&quot;:</p>
* <pre>
* &lt;symlink action=&quot;record&quot; linkfilename=&quot;dir.links&quot;&gt;
* &lt;fileset dir=&quot;${dir.top}&quot; includes=&quot;subdir&#47;**&quot; /&gt;
* &lt;/symlink&gt;
* </pre>
*
* <p>Recreate the links recorded in the previous example:</p>
* <pre>
* &lt;symlink action=&quot;recreate&quot;&gt;
* &lt;fileset dir=&quot;${dir.top}&quot; includes=&quot;subdir&#47;**&#47;dir.links&quot; /&gt;
* &lt;/symlink&gt;
* </pre>
*
* <p>Delete a link named &quot;foo&quot; to a resource named
* &quot;bar.foo&quot; in subdir:</p>
* <pre>
* &lt;symlink action=&quot;delete&quot; link=&quot;${dir.top}/foo&quot;/&gt;
* </pre>
*
* <p><strong>Note:</strong> Starting Ant version 1.10.2, this task relies on the symbolic link support
* introduced in Java 7 through the {@link Files} APIs.
*/
public class Symlink extends DispatchTask {
private String resource;
private String link;
private List<FileSet> fileSets = new ArrayList<>();
private String linkFileName;
private boolean overwrite;
private boolean failonerror;
private boolean executing = false;
/**
* Initialize the task.
* @throws BuildException on error.
*/
@Override
public void init() throws BuildException {
super.init();
setDefaults();
}
/**
* The standard method for executing any task.
* @throws BuildException on error.
*/
@Override
public synchronized void execute() throws BuildException {
if (executing) {
throw new BuildException(
"Infinite recursion detected in Symlink.execute()");
}
try {
executing = true;
DispatchUtils.execute(this);
} finally {
executing = false;
}
}
/**
* Create a symlink.
* @throws BuildException on error.
* @since Ant 1.7
*/
public void single() throws BuildException {
try {
if (resource == null) {
handleError("Must define the resource to symlink to!");
return;
}
if (link == null) {
handleError("Must define the link name for symlink!");
return;
}
doLink(resource, link);
} finally {
setDefaults();
}
}
/**
* Delete a symlink.
* @throws BuildException on error.
* @since Ant 1.7
*/
public void delete() throws BuildException {
try {
if (link == null) {
handleError("Must define the link name for symlink!");
return;
}
final Path linkPath = Paths.get(link);
if (!Files.isSymbolicLink(linkPath)) {
log("Skipping deletion of " + linkPath + " since it's not a symlink", Project.MSG_VERBOSE);
// just ignore and silently return (this is consistent
// with the current, 1.9.x versions, of Ant)
return;
}
log("Removing symlink: " + link);
deleteSymLink(linkPath);
} catch (IOException ioe) {
handleError(ioe.getMessage());
} finally {
setDefaults();
}
}
/**
* Restore symlinks.
* @throws BuildException on error.
* @since Ant 1.7
*/
public void recreate() throws BuildException {
try {
if (fileSets.isEmpty()) {
handleError(
"File set identifying link file(s) required for action recreate");
return;
}
final Properties links = loadLinks(fileSets);
for (final String link : links.stringPropertyNames()) {
final String resource = links.getProperty(link);
try {
if (Files.isSymbolicLink(Paths.get(link)) &&
new File(link).getCanonicalPath().equals(new File(resource).getCanonicalPath())) {
// it's already a symlink and the symlink target is the same
// as the target noted in the properties file. So there's no
// need to recreate it
log("not recreating " + link + " as it points to the correct target already",
Project.MSG_DEBUG);
continue;
}
} catch (IOException e) {
final String errMessage = "Failed to check if path " + link + " is a symbolic link, linking to " + resource;
if (failonerror) {
throw new BuildException(errMessage, e);
}
// log and continue
log(errMessage, Project.MSG_INFO);
continue;
}
// create the link
this.doLink(resource, link);
}
} finally {
setDefaults();
}
}
/**
* Record symlinks.
* @throws BuildException on error.
* @since Ant 1.7
*/
public void record() throws BuildException {
try {
if (fileSets.isEmpty()) {
handleError("Fileset identifying links to record required");
return;
}
if (linkFileName == null) {
handleError("Name of file to record links in required");
return;
}
// create a map to group them by parent directory:
Map<File, List<File>> byDir = new HashMap<>();
// get an Iterator of file objects representing links (canonical):
findLinks(fileSets).forEach(link -> byDir
.computeIfAbsent(link.getParentFile(), k -> new ArrayList<>())
.add(link));
// write a Properties file in each directory:
byDir.forEach((dir, linksInDir) -> {
Properties linksToStore = new Properties();
// fill up a Properties object with link and resource names:
for (File link : linksInDir) {
try {
linksToStore.put(link.getName(), link.getCanonicalPath());
} catch (IOException ioe) {
handleError("Couldn't get canonical name of parent link");
}
}
writePropertyFile(linksToStore, dir);
});
} finally {
setDefaults();
}
}
/**
* Return all variables to their default state for the next invocation.
* @since Ant 1.7
*/
private void setDefaults() {
resource = null;
link = null;
linkFileName = null;
failonerror = true; // default behavior is to fail on an error
overwrite = false; // default behavior is to not overwrite
setAction("single"); // default behavior is make a single link
fileSets.clear();
}
/**
* Set overwrite mode. If set to false (default)
* the task will not overwrite existing links, and may stop the build
* if a link already exists depending on the setting of failonerror.
*
* @param owrite If true overwrite existing links.
*/
public void setOverwrite(boolean owrite) {
this.overwrite = owrite;
}
/**
* Set failonerror mode. If set to true (default) the entire build fails
* upon error; otherwise the error is logged and the build will continue.
*
* @param foe If true throw BuildException on error, else log it.
*/
public void setFailOnError(boolean foe) {
this.failonerror = foe;
}
/**
* Set the action to be performed. May be &quot;single&quot;,
* &quot;delete&quot;, &quot;recreate&quot; or &quot;record&quot;.
*
* @param action The action to perform.
*/
@Override
public void setAction(String action) {
super.setAction(action);
}
/**
* Set the name of the link. Used when action = &quot;single&quot;.
*
* @param link The name for the link.
*/
public void setLink(String link) {
this.link = link;
}
/**
* Set the name of the resource to which a link should be created.
* Used when action = &quot;single&quot;.
*
* @param src The resource to be linked.
*/
public void setResource(String src) {
this.resource = src;
}
/**
* Set the name of the file to which links will be written.
* Used when action = &quot;record&quot;.
*
* @param lf The name of the file to write links to.
*/
public void setLinkfilename(String lf) {
this.linkFileName = lf;
}
/**
* Add a fileset to this task.
*
* @param set The fileset to add.
*/
public void addFileset(FileSet set) {
fileSets.add(set);
}
/**
* Delete a symlink (without deleting the associated resource).
*
* <p>This is a convenience method that simply invokes {@link #deleteSymlink(File)}</p>
*
* @param path A string containing the path of the symlink to delete.
* @throws IOException If the deletion attempt fails
*
* @deprecated use {@link Files#delete(Path)} instead
*/
@Deprecated
public static void deleteSymlink(final String path)
throws IOException {
deleteSymlink(Paths.get(path).toFile());
}
/**
* Delete a symlink (without deleting the associated resource).
*
* <p>This is a utility method that removes a symlink without removing
* the resource that the symlink points to. If it is accidentally invoked
* on a real file, the real file will not be harmed and instead this method
* returns silently.</p>
*
* <p>Since Ant 1.10.2 this method relies on the {@link Files#isSymbolicLink(Path)}
* and {@link Files#delete(Path)} to check and delete the symlink
* </p>
*
* @param linkfil A <code>File</code> object of the symlink to delete. Cannot be null.
* @throws IOException If the attempt to delete runs into exception
*
* @deprecated use {@link Files#delete(Path)} instead
*/
@Deprecated
public static void deleteSymlink(final File linkfil)
throws IOException {
if (!Files.isSymbolicLink(linkfil.toPath())) {
return;
}
deleteSymLink(linkfil.toPath());
}
/**
* Write a properties file. This method uses <code>Properties.store</code>
* and thus may throw exceptions that occur while writing the file.
*
* @param properties The properties object to be written.
* @param dir The directory for which we are writing the links.
* @throws BuildException if the property file could not be written
*/
private void writePropertyFile(Properties properties, File dir)
throws BuildException {
try (BufferedOutputStream bos = new BufferedOutputStream(
Files.newOutputStream(new File(dir, linkFileName).toPath()))) {
properties.store(bos, "Symlinks from " + dir);
} catch (IOException ioe) {
throw new BuildException(ioe, getLocation());
}
}
/**
* Handle errors based on the setting of failonerror.
*
* @param msg The message to log, or include in the
* <code>BuildException</code>.
* @throws BuildException with the message if failonerror=true
*/
private void handleError(String msg) {
if (failonerror) {
throw new BuildException(msg);
}
log(msg);
}
/**
* Conduct the actual construction of a link.
*
* @param resource The path of the resource we are linking to.
* @param link The name of the link we wish to make.
* @throws BuildException when things go wrong
*/
private void doLink(String resource, String link) throws BuildException {
final Path linkPath = Paths.get(link);
final Path target = Paths.get(resource);
final boolean alreadyExists = Files.exists(linkPath, LinkOption.NOFOLLOW_LINKS);
if (!alreadyExists) {
// if the path (at which the link is expected to be created) isn't already present
// then we just go ahead and attempt to symlink
try {
log("creating symlink " + linkPath + " -> " + target, Project.MSG_DEBUG);
Files.createSymbolicLink(linkPath, target);
} catch (IOException e) {
if (failonerror) {
throw new BuildException("Failed to create symlink " + link + " to target " + resource, e);
}
log("Unable to create symlink " + link + " to target " + resource, e, Project.MSG_INFO);
}
return;
}
// file already exists, see if we are allowed to overwrite
if (!overwrite) {
log("Skipping symlink creation, since file at " + link + " already exists and overwrite is set to false", Project.MSG_INFO);
return;
}
// we have been asked to overwrite, so we now do the necessary steps
// initiate a deletion of the existing file
final boolean existingFileDeleted = linkPath.toFile().delete();
if (!existingFileDeleted) {
handleError("Deletion of file at " + link + " failed, while trying to overwrite it with a symlink");
return;
}
try {
log("creating symlink " + linkPath + " -> " + target + " after removing original",
Project.MSG_DEBUG);
Files.createSymbolicLink(linkPath, target);
} catch (IOException e) {
if (failonerror) {
throw new BuildException("Failed to create symlink " + link + " to target " + resource, e);
}
log("Unable to create symlink " + link + " to target " + resource, e, Project.MSG_INFO);
}
}
/**
* Find all the links in all supplied filesets.
*
* <p>This method is invoked when the action attribute is
* &quot;record&quot;. This means that filesets are interpreted
* as the directories in which links may be found.</p>
*
* @param fileSets The filesets specified by the user.
* @return A Set of <code>File</code> objects containing the
* links (with canonical parent directories).
*/
private Set<File> findLinks(List<FileSet> fileSets) {
final Set<File> result = new HashSet<>();
for (FileSet fs : fileSets) {
DirectoryScanner ds = fs.getDirectoryScanner(getProject());
File dir = fs.getDir(getProject());
Stream.of(ds.getIncludedFiles(), ds.getIncludedDirectories())
.flatMap(Stream::of).forEach(path -> {
try {
final File f = new File(dir, path);
final File pf = f.getParentFile();
final String name = f.getName();
// we use the canonical path of the parent dir in which the (potential)
// link resides
final File parentDirCanonicalizedFile = new File(pf.getCanonicalPath(), name);
if (Files.isSymbolicLink(parentDirCanonicalizedFile.toPath())) {
result.add(parentDirCanonicalizedFile);
}
} catch (IOException e) {
handleError("IOException: " + path + " omitted");
}
});
}
return result;
}
/**
* Load links from properties files included in one or more FileSets.
*
* <p>This method is only invoked when the action attribute is set to
* &quot;recreate&quot;. The filesets passed in are assumed to specify the
* names of the property files with the link information and the
* subdirectories in which to look for them.</p>
*
* @param fileSets The <code>FileSet</code>s for this task.
* @return The links to be made.
*/
private Properties loadLinks(List<FileSet> fileSets) {
Properties finalList = new Properties();
// loop through the supplied file sets:
for (FileSet fs : fileSets) {
DirectoryScanner ds = new DirectoryScanner();
fs.setupDirectoryScanner(ds, getProject());
ds.setFollowSymlinks(false);
ds.scan();
File dir = fs.getDir(getProject());
// load included files as properties files:
for (String name : ds.getIncludedFiles()) {
File inc = new File(dir, name);
File pf = inc.getParentFile();
Properties links = new Properties();
try (InputStream is = new BufferedInputStream(
Files.newInputStream(inc.toPath()))) {
links.load(is);
pf = pf.getCanonicalFile();
} catch (FileNotFoundException fnfe) {
handleError("Unable to find " + name + "; skipping it.");
continue;
} catch (IOException ioe) {
handleError("Unable to open " + name
+ " or its parent dir; skipping it.");
continue;
}
try {
links.store(new PrintStream(
new LogOutputStream(this, Project.MSG_INFO)),
"listing properties");
} catch (IOException ex) {
log("failed to log unshortened properties");
links.list(new PrintStream(
new LogOutputStream(this, Project.MSG_INFO)));
}
// Write the contents to our master list of links
// This method assumes that all links are defined in
// terms of absolute paths, or paths relative to the
// working directory:
for (String key : links.stringPropertyNames()) {
finalList.put(new File(pf, key).getAbsolutePath(),
links.getProperty(key));
}
}
}
return finalList;
}
private static void deleteSymLink(final Path path) throws IOException {
// Implementation note: We intentionally use java.io.File#delete() instead of
// java.nio.file.Files#delete(Path) since it turns out that the latter doesn't
// update/clear the "canonical file paths cache" maintained by the JRE FileSystemProvider.
// Not clearing/updating that cache results in this deleted (and later recreated) symlink
// to point to a wrong/outdated target for a few seconds (30 seconds is the time the JRE
// maintains the cache entries for). All this is implementation detail of the JRE and
// is a JRE bug https://mail.openjdk.java.net/pipermail/core-libs-dev/2017-December/050540.html,
// but given that it affects our tests (SymlinkTest#testRecreate consistently fails
// on MacOS/Unix) as well as the Symlink task, it makes sense to use this API instead of
// the Files#delete(Path) API
final boolean deleted = path.toFile().delete();
if (!deleted) {
throw new IOException("Could not delete symlink at " + path);
}
}
}