blob: 4e286fb17f4c075d34b208ebaec1ec8182a1d554 [file] [log] [blame]
package org.purl.wf4ever.robundle;
import static java.nio.file.StandardCopyOption.ATOMIC_MOVE;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static org.purl.wf4ever.robundle.utils.PathHelper.relativizeFromBase;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.CopyOption;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.text.MessageFormat;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.HierarchicalINIConfiguration;
import org.purl.wf4ever.robundle.fs.BundleFileSystem;
import org.purl.wf4ever.robundle.fs.BundleFileSystemProvider;
import org.purl.wf4ever.robundle.utils.RecursiveCopyFileVisitor;
import org.purl.wf4ever.robundle.utils.RecursiveDeleteVisitor;
import org.purl.wf4ever.robundle.utils.TemporaryFiles;
/**
* Utility functions for dealing with RO bundles.
* <p>
* The style of using this class is similar to that of {@link Files}. In fact, a
* RO bundle is implemented as a set of {@link Path}s.
*
* @author Stian Soiland-Reyes
*
*/
public class Bundles {
private static final String ANNOTATIONS = "annotations";
private static final Charset ASCII = Charset.forName("ASCII");
private static final String DOT_RO = ".ro";
protected static final String DOT_URL = ".url";
private static final String INI_INTERNET_SHORTCUT = "InternetShortcut";
private static final String INI_URL = "URL";
private static final Charset LATIN1 = Charset.forName("Latin1");
private static final String MANIFEST_JSON = "manifest.json";
private static final Charset UTF8 = Charset.forName("UTF-8");
public static void closeAndSaveBundle(Bundle bundle, Path destination)
throws IOException {
Path zipPath = closeBundle(bundle);
if (bundle.isDeleteOnClose()) {
safeMove(zipPath, destination);
} else {
safeCopy(zipPath, destination);
}
}
public static Path closeBundle(Bundle bundle) throws IOException {
Path path = bundle.getSource();
bundle.close(false);
return path;
}
public static void copyRecursively(final Path source,
final Path destination, final CopyOption... copyOptions)
throws IOException {
RecursiveCopyFileVisitor.copyRecursively(source, destination,
copyOptions);
}
public static Bundle createBundle() throws IOException {
BundleFileSystem fs = BundleFileSystemProvider
.newFileSystemFromTemporary();
return new Bundle(fs.getRootDirectory(), true);
}
public static Bundle createBundle(Path path) throws IOException {
BundleFileSystem fs = BundleFileSystemProvider
.newFileSystemFromNew(path);
return new Bundle(fs.getRootDirectory(), false);
}
public static void deleteRecursively(Path p) throws IOException {
RecursiveDeleteVisitor.deleteRecursively(p);
}
protected static String filenameWithoutExtension(Path entry) {
String fileName = entry.getFileName().toString();
int lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
// return fileName;
return fileName.replace("/", "");
}
return fileName.substring(0, lastDot);
}
public static Path getAnnotations(Bundle bundle) throws IOException {
Path dir = bundle.getFileSystem().getPath(DOT_RO, ANNOTATIONS);
Files.createDirectories(dir);
return dir;
}
public static Path getManifestPath(Bundle bundle) {
return bundle.getRoot().resolve(DOT_RO).resolve(MANIFEST_JSON);
}
public static String getMimeType(Bundle bundle) throws IOException {
Path mimetypePath = bundle.getRoot().resolve(
BundleFileSystemProvider.MIMETYPE_FILE);
String mimetype = getStringValue(mimetypePath);
if (mimetype == null || mimetype.isEmpty()) {
return BundleFileSystemProvider.APPLICATION_VND_WF4EVER_ROBUNDLE_ZIP;
}
return mimetype.trim();
}
public static URI getReference(Path path) throws IOException {
if (path == null || isMissing(path)) {
return null;
}
if (!isReference(path)) {
throw new IllegalArgumentException("Not a reference: " + path);
}
// Note: Latin1 is chosen here because it would not bail out on
// "strange" characters. We actually parse the URL as ASCII
path = withExtension(path, DOT_URL);
try (BufferedReader r = Files.newBufferedReader(path, LATIN1)) {
HierarchicalINIConfiguration ini = new HierarchicalINIConfiguration();
ini.load(r);
String urlStr = ini.getSection(INI_INTERNET_SHORTCUT).getString(
INI_URL);
// String urlStr = ini.get(INI_INTERNET_SHORTCUT, INI_URL);
if (urlStr == null) {
throw new IOException("Invalid/unsupported URL format: " + path);
}
return URI.create(urlStr);
} catch (ConfigurationException e) {
throw new IOException("Can't parse reference: " + path, e);
}
}
public static String getStringValue(Path path) throws IOException {
if (path == null || isMissing(path)) {
return null;
}
if (!isValue(path)) {
throw new IllegalArgumentException("Not a value: " + path);
}
return new String(Files.readAllBytes(path), UTF8);
}
public static boolean isMissing(Path item) {
return !Files.exists(item) && !isReference(item);
}
public static boolean isReference(Path path) {
return Files.isRegularFile(withExtension(path, DOT_URL));
}
public static boolean isValue(Path path) {
return !isReference(path) && Files.isRegularFile(path);
}
public static Bundle openBundle(InputStream in) throws IOException {
Path path = TemporaryFiles.temporaryBundle();
Files.copy(in, path);
Bundle bundle = openBundle(path);
bundle.setDeleteOnClose(true);
return bundle;
}
public static Bundle openBundle(Path zip) throws IOException {
BundleFileSystem fs = BundleFileSystemProvider
.newFileSystemFromExisting(zip);
return new Bundle(fs.getRootDirectory(), false);
}
public static Bundle openBundle(URL url) throws IOException {
if ("file.".equals(url.getProtocol())) {
try {
return openBundle(Paths.get(url.toURI()));
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Invalid URL " + url, e);
}
} else {
try (InputStream in = url.openStream()) {
return openBundle(in);
}
}
}
public static Bundle openBundleReadOnly(Path zip) throws IOException {
Path tmpBundle = TemporaryFiles.temporaryBundle();
// BundleFileSystemProvider requires write-access, so we'll have to copy
// it
Files.copy(zip, tmpBundle);
BundleFileSystem fs = BundleFileSystemProvider
.newFileSystemFromExisting(tmpBundle);
// And this temporary file will be deleted afterwards
return new Bundle(fs.getRootDirectory(), true);
}
public static void safeCopy(Path source, Path destination)
throws IOException {
safeMoveOrCopy(source, destination, false);
}
public static void safeMove(Path source, Path destination)
throws IOException {
safeMoveOrCopy(source, destination, true);
}
protected static void safeMoveOrCopy(Path source, Path destination,
boolean move) throws IOException {
// First just try to do an atomic move with overwrite
if (move
&& source.getFileSystem().provider()
.equals(destination.getFileSystem().provider())) {
try {
Files.move(source, destination, ATOMIC_MOVE, REPLACE_EXISTING);
return;
} catch (AtomicMoveNotSupportedException ex) {
// Do the fallback by temporary files below
}
}
destination = destination.toAbsolutePath();
String tmpName = destination.getFileName().toString();
Path tmpDestination = Files.createTempFile(destination.getParent(),
tmpName, ".tmp");
Path backup = null;
try {
if (move) {
// This might do a copy if filestores differ
// .. hence to avoid an incomplete (and partially overwritten)
// destination, we do it first to a temporary file
Files.move(source, tmpDestination, REPLACE_EXISTING);
} else {
Files.copy(source, tmpDestination, REPLACE_EXISTING);
}
if (Files.exists(destination)) {
if (Files.isDirectory(destination)) {
// ensure it is empty
try (DirectoryStream<Path> ds = Files
.newDirectoryStream(destination)) {
for (Path p : ds) {
throw new DirectoryNotEmptyException(
destination.toString());
}
}
}
// Keep the files for roll-back in case it goes bad
backup = Files.createTempFile(destination.getParent(), tmpName,
".orig");
Files.move(destination, backup, REPLACE_EXISTING);
}
// OK ; let's swap over
try {
// prefer ATOMIC_MOVE
Files.move(tmpDestination, destination, REPLACE_EXISTING,
ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ex) {
// possibly a network file system as src/dest should be in same
// folder
Files.move(tmpDestination, destination, REPLACE_EXISTING);
} finally {
if (!Files.exists(destination) && backup != null) {
// Restore the backup
Files.move(backup, destination);
}
}
// It went well, tidy up
if (backup != null) {
Files.deleteIfExists(backup);
}
} finally {
Files.deleteIfExists(tmpDestination);
}
}
public static void setMimeType(Bundle bundle, String mimetype)
throws IOException {
if (!ASCII.newEncoder().canEncode(mimetype)) {
throw new IllegalArgumentException("mimetype must be ASCII, not "
+ mimetype);
}
if (mimetype.contains("\n") || mimetype.contains("\r")) {
throw new IllegalArgumentException(
"mimetype can't contain newlines");
}
if (!mimetype.contains("/")) {
throw new IllegalArgumentException("Invalid mimetype: " + mimetype);
}
Path root = bundle.getRoot();
Path mimetypePath = bundle.getRoot().resolve(
BundleFileSystemProvider.MIMETYPE_FILE);
if (!Files.isRegularFile(mimetypePath)) {
// It would require low-level zip-modification to properly add
// 'mimetype' now
throw new IOException("Special file '"
+ BundleFileSystemProvider.MIMETYPE_FILE
+ "' missing from bundle, can't set mimetype");
}
setStringValue(mimetypePath, mimetype);
}
public static Path setReference(Path path, URI ref) throws IOException {
path = withExtension(path, DOT_URL);
// We'll save a IE-like .url "Internet shortcut" in INI format.
// HierarchicalINIConfiguration ini = new
// HierarchicalINIConfiguration();
// ini.getSection(INI_INTERNET_SHORTCUT).addProperty(INI_URL,
// ref.toASCIIString());
// Ini ini = new Wini();
// ini.getConfig().setLineSeparator("\r\n");
// ini.put(INI_INTERNET_SHORTCUT, INI_URL, ref.toASCIIString());
/*
* Neither of the above create a .url that is compatible with Safari on
* Mac OS (which expects "URL=" rather than "URL = ", so instead we make
* it manually with MessageFormat.format:
*/
// Includes a terminating double line-feed -- which Safari might also
// need
String iniTmpl = "[{0}]\r\n{1}={2}\r\n\r\n";
String ini = MessageFormat.format(iniTmpl, INI_INTERNET_SHORTCUT,
INI_URL, ref.toASCIIString());
// NOTE: We use Latin1 here, but because of
try (BufferedWriter w = Files
.newBufferedWriter(path, ASCII,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.CREATE)) {
// ini.save(w);
// ini.store(w);
w.write(ini);
// } catch (ConfigurationException e) {
// throw new IOException("Can't write shortcut to " + path, e);
}
return path;
}
public static void setStringValue(Path path, String string)
throws IOException {
Files.write(path, string.getBytes(UTF8),
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE);
}
public static Path uriToBundlePath(Bundle bundle, URI uri) {
URI rootUri = bundle.getRoot().toUri();
uri = relativizeFromBase(uri, rootUri);
if (uri.isAbsolute() || uri.getFragment() != null) {
return null;
}
return bundle.getFileSystem().provider().getPath(rootUri.resolve(uri));
}
protected static Path withExtension(Path path, String extension) {
if (!extension.isEmpty() && !extension.startsWith(".")) {
throw new IllegalArgumentException(
"Extension must be empty or start with .");
}
String p = path.getFileName().toString();
if (!extension.isEmpty()
&& p.toLowerCase().endsWith(extension.toLowerCase())) {
return path;
}
// Everything after the last . - or just the end
String newP = p.replaceFirst("(\\.[^.]*)?$", extension);
return path.resolveSibling(newP);
}
}