package aQute.bnd.deployer.repository; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintStream; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URL; import java.net.URLEncoder; import java.security.DigestOutputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import aQute.bnd.service.ResourceHandle; import aQute.bnd.service.url.URLConnector; import aQute.lib.hex.Hex; import aQute.lib.io.IO; import aQute.lib.io.IOConstants; import aQute.service.reporter.Reporter; /** * <p> * This resource handler downloads remote resources on demand, and caches them * as local files. Resources that are already local (i.e. <code>file:...</code> * URLs) are returned directly. * </p> * <p> * Two alternative caching modes are available. When the mode is * {@link CachingMode#PreferCache}, the cached file will always be returned if * it exists; therefore to refresh from the remote resource it will be necessary * to delete the cache. When the mode is {@link CachingMode#PreferRemote}, the * first call to {@link #request()} will always attempt to download the remote * resource, and only uses the pre-downloaded cache if the remote could not be * downloaded (e.g. because the network is offline). * </p> * * @author njbartlett */ public class CachingUriResourceHandle implements ResourceHandle { static final int BUFFER_SIZE = IOConstants.PAGE_SIZE * 1; private static final String SHA_256 = "SHA-256"; @Deprecated public static enum CachingMode { /** * Always use the cached file, if it exists. */ @Deprecated PreferCache, /** * Download the remote resource if possible, falling back to the cached * file if remote fails. Subsequently the cached resource will be used. */ @Deprecated PreferRemote; } static final String FILE_SCHEME = "file"; static final String FILE_PREFIX = FILE_SCHEME + ":"; static final String HTTP_SCHEME = "http"; static final String HTTP_PREFIX = HTTP_SCHEME + ":"; static final String UTF_8 = "UTF-8"; final File cacheDir; final URLConnector connector; // The resolved, absolute URL of the resource final URL url; String sha; // The local file, if the resource IS a file, otherwise null. final File localFile; // The cached file copy of the resource, if it is remote and has been // downloaded. final File cachedFile; final File shaFile; final CachingMode mode; Reporter reporter; public CachingUriResourceHandle(URI uri, final File cacheDir, URLConnector connector, String sha) throws IOException { this.cacheDir = cacheDir; this.connector = connector; this.mode = CachingMode.PreferRemote; this.sha = sha; if (!uri.isAbsolute()) throw new IllegalArgumentException("Relative URIs are not permitted " + uri); if (FILE_SCHEME.equals(uri.getScheme())) { this.localFile = new File(uri.getPath()); this.url = uri.toURL(); this.cachedFile = null; this.shaFile = null; } else { this.url = uri.toURL(); this.localFile = null; this.cachedFile = mapRemoteURL(url); this.shaFile = mapSHAFile(cachedFile); } } public void setReporter(Reporter reporter) { this.reporter = reporter; } static File resolveFile(String baseFileName, String fileName) { File resolved; File baseFile = new File(baseFileName); if (baseFile.isDirectory()) resolved = new File(baseFile, fileName); else if (baseFile.isFile()) resolved = new File(baseFile.getParentFile(), fileName); else throw new IllegalArgumentException( "Cannot resolve relative to non-existant base file path: " + baseFileName); return resolved; } private File mapRemoteURL(URL url) throws UnsupportedEncodingException, IOException { String localDirName; String localFileName; String fullUrl = url.toExternalForm(); int lastSlashIndex = fullUrl.lastIndexOf('/'); File localDir; if (lastSlashIndex > -1) { localDirName = URLEncoder.encode(fullUrl.substring(0, lastSlashIndex), UTF_8); localDir = new File(cacheDir, localDirName); if (localDir.exists() && !localDir.isDirectory()) { localDir = cacheDir; localFileName = URLEncoder.encode(fullUrl, UTF_8); } else { localFileName = URLEncoder.encode(fullUrl.substring(lastSlashIndex + 1), UTF_8); } } else { localDir = cacheDir; localFileName = URLEncoder.encode(fullUrl, UTF_8); } IO.mkdirs(localDir); return new File(localDir, localFileName); } private static File mapSHAFile(File cachedFile) { return new File(cachedFile.getAbsolutePath() + AbstractIndexedRepo.REPO_INDEX_SHA_EXTENSION); } public String getName() { return url.toString(); } public Location getLocation() { Location result; if (localFile != null) result = Location.local; else if (cachedFile.exists()) result = Location.remote_cached; else result = Location.remote; return result; } public File request() throws Exception { if (localFile != null) return localFile; if (cachedFile == null) throw new IllegalStateException( "Invalid URLResourceHandle: both local file and cache file location are uninitialised."); // Check whether the cached copy exist and has the right SHA. boolean cacheExists = cachedFile.isFile(); boolean cacheValidated = false; if (cacheExists && sha != null) { String cachedSHA = getCachedSHA(); cacheValidated = sha.equalsIgnoreCase(cachedSHA); } if (cacheValidated) return cachedFile; try (InputStream data = connector.connect(url)){ // Save the data to the cache ensureCacheDirExists(); String serverSHA = copyWithSHA(data, IO.outputStream(cachedFile)); // Check the SHA of the received data if (sha != null && !sha.equalsIgnoreCase(serverSHA)) { IO.delete(shaFile); IO.delete(cachedFile); throw new IOException(String.format("Invalid SHA on remote resource at %s", url)); } saveSHAFile(serverSHA); return cachedFile; } catch (IOException e) { if (sha == null) { // Remote access failed, use the cache if it exists AND if the // original SHA was not known. if (cacheExists) { if (reporter != null) reporter.warning("Using local cache; downloading %s failed (%s).", url, e); return cachedFile; } else { if (reporter != null) reporter.error("Downloading %s failed (%s) and cache file %s is not available. Trace: %s", url, e, cachedFile, collectStackTrace(e)); throw new IOException(String.format( "Downloading %s failed and cache file %s is not available, see log for details.", url, cachedFile)); } } else { // Can only get here if the cache was missing or didn't match // the SHA, and remote access failed. if (reporter != null) reporter.error( "Downloading %s failed (%s) and cache file %s is not available or doesn't match the expected checksum. Trace: %s", url, e, cachedFile, collectStackTrace(e)); throw new IOException(String.format( "Downloading %s failed and cache file %s is not available or doesn't match the expected checksum, see log for details.", url, cachedFile)); } } } private String copyWithSHA(InputStream input, OutputStream output) throws IOException { try { MessageDigest digest = MessageDigest.getInstance(SHA_256); DigestOutputStream digestOutput = new DigestOutputStream(output, digest); IO.copy(input, digestOutput); return Hex.toHexString(digest.digest()); } catch (NoSuchAlgorithmException e) { // Can't happen... hopefully... throw new IOException(e.getMessage(), e); } finally { IO.close(input); IO.close(output); } } private void ensureCacheDirExists() throws IOException { if (cacheDir.isDirectory()) return; if (cacheDir.exists()) { String message = String.format( "Cannot create cache directory in path %s: the path exists but is not a directory", cacheDir.getCanonicalPath()); if (reporter != null) reporter.error(message); throw new IOException(message); } try { IO.mkdirs(cacheDir); } catch (IOException e) { if (reporter != null) { String message = String.format("Failed to create cache directory in path %s", cacheDir.getCanonicalPath()); reporter.exception(e, message); } throw e; } } private static String collectStackTrace(Throwable t) { try { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); PrintStream pps = new PrintStream(buffer, false, UTF_8); t.printStackTrace(pps); return buffer.toString(UTF_8); } catch (UnsupportedEncodingException e) { return null; } } String getCachedSHA() throws IOException { String content = readSHAFile(); if (content == null) { content = calculateSHA(cachedFile); if (content != null) { saveSHAFile(content); } } return content; } static String calculateSHA(File file) throws IOException { if (file == null || !file.exists()) { return null; } try { MessageDigest digest = MessageDigest.getInstance(SHA_256); IO.copy(file, digest); return Hex.toHexString(digest.digest()); } catch (NoSuchAlgorithmException e) { // Can't happen... hopefully... throw new IOException(e.getMessage(), e); } } String readSHAFile() throws IOException { String result; if (shaFile != null && shaFile.isFile()) result = IO.collect(shaFile); else result = null; return result; } void saveSHAFile(String contents) { try { IO.store(contents, shaFile); } catch (IOException e) { IO.delete(shaFile); // Errors saving the SHA should not interfere with the download if (reporter != null) reporter.exception(e, "Failed to save SHA file %s (%s)", shaFile, e); } } }