blob: 10a23e8e115b4fcc57249b31df67e076e0ca8aeb [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
*
* http://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.
*/
package org.netbeans.nbbuild.extlibs;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.util.FileUtils;
/**
* Task to retrieve named files (generally large binaries such as ZIPs) from a repository.
* Similar to a very simplified version of Ivy, but correctly handles binaries
* with missing or irrelevant version numbers, since it is based on a hash of contents.
* You keep one or more manifests under version control which enumerate files and their SHA-1 hashes.
* Then just run this task to download any missing files.
* Remember to specify the binaries as "ignorable" to your version control system.
* You can also run it in a clean mode which will remove the binaries.
* At the end of this source file is a sample CGI script and matching form which you can run on the server
* to permit people to upload files to the correct repository paths.
* Motivation: http://wiki.netbeans.org/wiki/view/HgMigration#section-HgMigration-Binaries
*/
public class DownloadBinaries extends Task {
private File cache;
/**
* Location of per-user cache of already downloaded binaries.
* Optional; no cache will be used if unset.
* The directory will be created if it does not yet exist.
*/
public void setCache(File cache) {
this.cache = cache;
}
private String server;
/**
* URL prefix for the server repository.
* Should generally include a trailing slash.
* You may include multiple server URLs separated by spaces
* in which case they will be tried in order.
* To use a local repository, simply specify e.g. <code>file:/repo/</code> as the URL.
*/
public void setServer(String server) {
this.server = server;
}
private final List<FileSet> manifests = new ArrayList<>();
/**
* Add one or more manifests of files to download.
* Each manifest is a text file; lines beginning with # are ignored.
* All other lines must be entries of the form
* <pre>
* 0123456789ABCDEF something-1.0.jar
* </pre>
* consisting of an SHA-1 hash followed by a filename.
* The filename is relative to the manifest, usually a simple basename.
* If the file exists and has the specified hash, nothing is done.
* If it has the wrong hash, the task aborts with an error message.
* If it is missing, it is downloaded from the server (or copied from cache)
* using a filename derived from the basename of the file in the manifest and its hash.
* For example, the above line with a server of <code>http://nowhere.net/repo/</code>
* would try to download
* <pre>
* http://nowhere.net/repo/0123456789ABCDEF-something-1.0.jar
* </pre>
* Any version number etc. in the filename is purely informational;
* the "up to date" check is entirely based on the hash.
*/
public void addManifest(FileSet manifest) {
manifests.add(manifest);
}
private boolean clean;
/**
* If true, rather than creating binary files, will delete them.
* Any cache is ignored in this case.
* If a binary does not match its hash, the build is aborted:
* the file might be a precious customized version and should not be blindly deleted.
*/
public void setClean(boolean clean) {
this.clean = clean;
}
@Override
public void execute() throws BuildException {
for (FileSet fs : manifests) {
DirectoryScanner scanner = fs.getDirectoryScanner(getProject());
File basedir = scanner.getBasedir();
for (String include : scanner.getIncludedFiles()) {
File manifest = new File(basedir, include);
log("Scanning: " + manifest, Project.MSG_VERBOSE);
try {
try (InputStream is = new FileInputStream(manifest)) {
BufferedReader r = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("#")) {
continue;
}
if (line.trim().length() == 0) {
continue;
}
String[] hashAndFile = line.split(" ", 2);
if (hashAndFile.length < 2) {
throw new BuildException("Bad line '" + line + "' in " + manifest, getLocation());
}
if (MavenCoordinate.isMavenFile(hashAndFile[1])) {
MavenCoordinate mc = MavenCoordinate.fromGradleFormat(hashAndFile[1]);
fillInFile(hashAndFile[0], mc.toArtifactFilename(), manifest, () -> mavenFile(mc));
} else {
fillInFile(hashAndFile[0], hashAndFile[1], manifest, () -> legacyDownload(hashAndFile[0] + "-" + hashAndFile[1]));
}
}
}
} catch (IOException x) {
throw new BuildException("Could not open " + manifest + ": " + x, x, getLocation());
}
}
}
}
private byte[] mavenFile(MavenCoordinate mc) throws IOException {
String cacheName = mc.toMavenPath();
File local = new File(new File(new File(new File(System.getProperty("user.home")), ".m2"), "repository"), cacheName.replace('/', File.separatorChar));
final String url;
if (local.exists()) {
url = local.toURI().toString();
} else {
url = "https://repo.maven.apache.org/maven2/" + cacheName;
}
URL u = new URL(url);
return downloadFromServer(u);
}
private void fillInFile(String expectedHash, String baseName, File manifest, Downloader download) throws BuildException {
File f = new File(manifest.getParentFile(), baseName);
if (!clean) {
if (!f.exists() || !hash(f).equals(expectedHash)) {
log("Creating " + f);
String cacheName = expectedHash + "-" + baseName;
if (cache != null) {
cache.mkdirs();
File cacheFile = new File(cache, cacheName);
if (!cacheFile.exists()) {
doDownload(cacheName, cacheFile, expectedHash, download);
}
if (f.isFile() && !f.delete()) {
throw new BuildException("Could not delete " + f);
}
try {
FileUtils.getFileUtils().copyFile(cacheFile, f);
} catch (IOException x) {
throw new BuildException("Could not copy " + cacheFile + " to " + f + ": " + x, x, getLocation());
}
} else {
doDownload(cacheName, f, expectedHash, download);
}
}
String actualHash = hash(f);
if (!actualHash.equals(expectedHash)) {
throw new BuildException("File " + f + " requested by " + manifest + " to have hash " +
expectedHash + " actually had hash " + actualHash, getLocation());
}
log("Have " + f + " with expected hash", Project.MSG_VERBOSE);
} else {
if (f.exists()) {
String actualHash = hash(f);
if (!actualHash.equals(expectedHash)) {
throw new BuildException("File " + f + " requested by " + manifest + " to have hash " +
expectedHash + " actually had hash " + actualHash, getLocation());
}
log("Deleting " + f);
f.delete();
}
}
}
private void doDownload(String cacheName, File destination, String expectedHash, Downloader download) {
Throwable firstProblem = null;
try {
byte[] downloaded = download.download();
if (expectedHash != null) {
String actualHash = hash(new ByteArrayInputStream(downloaded));
if (!expectedHash.equals(actualHash)) {
throw new BuildException("Download of " + cacheName + " produced content with hash "
+ actualHash + " when " + expectedHash + " was expected", getLocation());
}
}
OutputStream os = new FileOutputStream(destination);
try {
os.write(downloaded);
} catch (IOException x) {
os.close();
destination.delete();
throw x;
}
os.close();
return ;
} catch (IOException x) {
String msg = "Could not download " + cacheName + " to " + destination + ": " + x;
log(msg, Project.MSG_WARN);
if (firstProblem == null) {
firstProblem = new IOException(msg).initCause(x);
}
}
throw new BuildException("Could not download " + cacheName + " from " + server + ": " + firstProblem, firstProblem, getLocation());
}
private byte[] legacyDownload(String cacheName) throws IOException {
if (server == null) {
throw new BuildException("Must specify a server to download files from", getLocation());
}
Throwable firstProblem = null;
for (String prefix : server.split(" ")) {
URL url = new URL(prefix + cacheName);
return downloadFromServer(url);
}
throw new BuildException("Could not download " + cacheName + " from " + server + ": " + firstProblem, firstProblem, getLocation());
}
private byte[] downloadFromServer(URL url) throws IOException {
log("Downloading: " + url);
URLConnection conn = openConnection(url);
int code = HttpURLConnection.HTTP_OK;
if (conn instanceof HttpURLConnection) {
code = ((HttpURLConnection) conn).getResponseCode();
}
if (code != HttpURLConnection.HTTP_OK) {
throw new IOException("Skipping download from " + url + " due to response code " + code);
}
try {
try (InputStream is = conn.getInputStream()) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int read;
while ((read = is.read(buf)) != -1) {
baos.write(buf, 0, read);
}
return baos.toByteArray();
}
} catch (IOException ex) {
throw new IOException("Cannot download: " + url + " due to: " + ex, ex);
}
}
interface Downloader {
public byte[] download() throws IOException;
}
private URLConnection openConnection(final URL url) throws IOException {
final URLConnection[] conn = { null };
final CountDownLatch connected = new CountDownLatch(1);
ExecutorService connectors = Executors.newFixedThreadPool(3);
connectors.submit(new Runnable() {
public void run() {
String httpProxy = System.getenv("http_proxy");
if (httpProxy != null) {
try {
URI uri = new URI(httpProxy);
InetSocketAddress address = InetSocketAddress.createUnresolved(uri.getHost(), uri.getPort());
Proxy proxy = new Proxy(Proxy.Type.HTTP, address);
URLConnection test = url.openConnection(proxy);
test.connect();
conn[0] = test;
connected.countDown();
} catch (IOException | URISyntaxException ex) {
log(ex, Project.MSG_ERR);
}
}
}
});
connectors.submit(new Runnable() {
public void run() {
String httpProxy = System.getenv("https_proxy");
if (httpProxy != null) {
try {
URI uri = new URI(httpProxy);
InetSocketAddress address = InetSocketAddress.createUnresolved(uri.getHost(), uri.getPort());
Proxy proxy = new Proxy(Proxy.Type.HTTP, address);
URLConnection test = url.openConnection(proxy);
test.connect();
conn[0] = test;
connected.countDown();
} catch (IOException | URISyntaxException ex) {
log(ex, Project.MSG_ERR);
}
}
}
});
connectors.submit(new Runnable() {
public void run() {
try {
URLConnection test = url.openConnection();
test.connect();
conn[0] = test;
connected.countDown();
} catch (IOException ex) {
log(ex, Project.MSG_ERR);
}
}
});
try {
connected.await(5, TimeUnit.SECONDS);
} catch (InterruptedException ex) {
}
if (conn[0] == null) {
throw new IOException("Cannot connect to " + url);
}
return conn[0];
}
private String hash(File f) {
try {
try (FileInputStream is = new FileInputStream(f)) {
return hash(is);
}
} catch (IOException x) {
throw new BuildException("Could not get hash for " + f + ": " + x, x, getLocation());
}
}
private String hash(InputStream is) throws IOException {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException x) {
throw new BuildException(x, getLocation());
}
byte[] buf = new byte[4096];
int r;
while ((r = is.read(buf)) != -1) {
digest.update(buf, 0, r);
}
return String.format("%040X", new BigInteger(1, digest.digest()));
}
static class MavenCoordinate {
private final String groupId;
private final String artifactId;
private final String version;
private final String extension;
private final String classifier;
private MavenCoordinate(String groupId, String artifactId, String version, String extension, String classifier) {
this.groupId = groupId;
this.artifactId = artifactId;
this.version = version;
this.extension = extension;
this.classifier = classifier;
}
public boolean hasClassifier() {
return (! classifier.isEmpty());
}
public String getGroupId() {
return groupId;
}
public String getArtifactId() {
return artifactId;
}
public String getVersion() {
return version;
}
public String getExtension() {
return extension;
}
public String getClassifier() {
return classifier;
}
/**
* @return filename of the artifact by maven convention:
* {@code artifact-version[-classifier].extension}
*/
public String toArtifactFilename() {
return String.format("%s-%s%s.%s",
getArtifactId(),
getVersion(),
hasClassifier() ? ("-" + getClassifier()) : "",
getExtension()
);
}
/**
* @return The repository path for an artifact by maven convention:
* {@code group/artifact/version/artifact-version[-classifier].extension}.
* In the group part all dots are replaced by a slash.
*/
public String toMavenPath() {
return String.format("%s/%s/%s/%s",
getGroupId().replace(".", "/"),
getArtifactId(),
getVersion(),
toArtifactFilename()
);
}
public static boolean isMavenFile(String gradleFormat) {
return gradleFormat.split(":").length > 2;
}
/**
* The maven coordinate is supplied in the form:
*
* <p>{@code group:name:version:classifier@extension}</p>
*
* <p>For the DownloadBinaries task the parts group, name and version
* are requiered. classifier and extension are optional. The extension
* has a default value of "jar".
*
* @param gradleFormat artifact coordinated to be parse as a MavenCoordinate
* @return
* @throws IllegalArgumentException if provided string fails to parse
*/
public static MavenCoordinate fromGradleFormat(String gradleFormat) {
if(! isMavenFile(gradleFormat)) {
throw new IllegalArgumentException("Supplied string is not in gradle dependency format: " + gradleFormat);
}
String[] coordinateExtension = gradleFormat.split("@", 2);
String extension;
String coordinate = coordinateExtension[0];
if (coordinateExtension.length > 1
&& (!coordinateExtension[1].trim().isEmpty())) {
extension = coordinateExtension[1];
} else {
extension = "jar";
}
String[] coordinates = coordinate.split(":");
String group = coordinates[0];
String artifact = coordinates[1];
String version = coordinates[2];
String classifier = "";
if (coordinates.length > 3) {
classifier = coordinates[3].trim();
}
return new MavenCoordinate(group, artifact, version, extension, classifier);
}
}
}
/*
Sample upload script (edit repository location as needed):
#!/usr/bin/env ruby
repository = '/tmp/repository'
require 'cgi'
require 'digest/sha1'
require 'date'
cgi = CGI.new
begin
if cgi.request_method == 'POST'
value = cgi['file']
content = value.read
name = value.original_filename.gsub(/\.\.|[^a-zA-Z0-9._+-]/, '_')
sha1 = Digest::SHA1.hexdigest(content).upcase
open("#{repository}/#{sha1}-#{name}", "w") do |f|
f.write content
end
open("#{repository}/log", "a") do |f|
f << "#{DateTime.now.to_s} #{sha1}-#{name} #{cgi.remote_user}\n"
end
cgi.out do <<RESPONSE
<html>
<head>
<title>Uploaded #{name}</title>
</head>
<body>
<p>Uploaded. Add to your manifest:</p>
<pre>#{sha1} #{name}</pre>
</body>
</html>
RESPONSE
end
else
cgi.out do <<FORM
<html>
<head>
<title>Upload a Binary</title>
</head>
<body>
<form method="POST" action="" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit" value="Upload">
</form>
</body>
</html>
FORM
end
end
rescue
cgi.out do
"Caught an exception: #{$!}"
end
end
*/