2 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
6 * Redistribution and use in source and binary forms, with or
7 * without modification, are permitted provided that the following
10 * - Redistributions of source code must retain the above copyright
11 * notice, this list of conditions and the following disclaimer.
13 * - Redistributions in binary form must reproduce the above
14 * copyright notice, this list of conditions and the following
15 * disclaimer in the documentation and/or other materials provided
16 * with the distribution.
18 * - Neither the name of the Git Development Community nor the
19 * names of its contributors may be used to endorse or promote
20 * products derived from this software without specific prior
23 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
24 * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
25 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
26 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
27 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
28 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
29 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
30 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
31 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
32 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
33 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
34 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
35 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38 package org
.spearce
.jgit
.transport
;
40 import java
.io
.EOFException
;
42 import java
.io
.FileInputStream
;
43 import java
.io
.FileNotFoundException
;
44 import java
.io
.IOException
;
45 import java
.io
.InputStream
;
46 import java
.io
.OutputStream
;
47 import java
.net
.HttpURLConnection
;
48 import java
.net
.Proxy
;
49 import java
.net
.ProxySelector
;
51 import java
.net
.URLConnection
;
52 import java
.security
.DigestOutputStream
;
53 import java
.security
.InvalidKeyException
;
54 import java
.security
.MessageDigest
;
55 import java
.security
.NoSuchAlgorithmException
;
56 import java
.text
.SimpleDateFormat
;
57 import java
.util
.ArrayList
;
58 import java
.util
.Collections
;
59 import java
.util
.Date
;
60 import java
.util
.HashSet
;
61 import java
.util
.Iterator
;
62 import java
.util
.List
;
63 import java
.util
.Locale
;
65 import java
.util
.Properties
;
67 import java
.util
.SortedMap
;
68 import java
.util
.TimeZone
;
69 import java
.util
.TreeMap
;
71 import javax
.crypto
.Mac
;
72 import javax
.crypto
.spec
.SecretKeySpec
;
74 import org
.spearce
.jgit
.awtui
.AwtAuthenticator
;
75 import org
.spearce
.jgit
.lib
.Constants
;
76 import org
.spearce
.jgit
.util
.Base64
;
77 import org
.spearce
.jgit
.util
.HttpSupport
;
78 import org
.spearce
.jgit
.util
.TemporaryBuffer
;
79 import org
.xml
.sax
.Attributes
;
80 import org
.xml
.sax
.InputSource
;
81 import org
.xml
.sax
.SAXException
;
82 import org
.xml
.sax
.XMLReader
;
83 import org
.xml
.sax
.helpers
.DefaultHandler
;
84 import org
.xml
.sax
.helpers
.XMLReaderFactory
;
87 * A simple HTTP REST client for the Amazon S3 service.
89 * This client uses the REST API to communicate with the Amazon S3 servers and
90 * read or write content through a bucket that the user has access to. It is a
91 * very lightweight implementation of the S3 API and therefore does not have all
92 * of the bells and whistles of popular client implementations.
94 * Authentication is always performed using the user's AWSAccessKeyId and their
95 * private AWSSecretAccessKey.
97 public class AmazonS3
{
98 private static final Set
<String
> SIGNED_HEADERS
;
100 private static final String HMAC
= "HmacSHA1";
102 private static final String DOMAIN
= "s3.amazonaws.com";
104 private static final String X_AMZ_ACL
= "x-amz-acl";
107 SIGNED_HEADERS
= new HashSet
<String
>();
108 SIGNED_HEADERS
.add("content-type");
109 SIGNED_HEADERS
.add("content-md5");
110 SIGNED_HEADERS
.add("date");
113 private static boolean isSignedHeader(final String name
) {
114 final String nameLC
= name
.toLowerCase();
115 return SIGNED_HEADERS
.contains(nameLC
) || nameLC
.startsWith("x-amz-");
118 private static String
toCleanString(final List
<String
> list
) {
119 final StringBuilder s
= new StringBuilder();
120 for (final String v
: list
) {
123 s
.append(v
.replaceAll("\n", "").trim());
128 private static String
remove(final Map
<String
, String
> m
, final String k
) {
129 final String r
= m
.remove(k
);
130 return r
!= null ? r
: "";
133 private static String
httpNow() {
134 final String tz
= "GMT";
135 final SimpleDateFormat fmt
;
136 fmt
= new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale
.US
);
137 fmt
.setTimeZone(TimeZone
.getTimeZone(tz
));
138 return fmt
.format(new Date()) + " " + tz
;
141 private static MessageDigest
newMD5() {
143 return MessageDigest
.getInstance("MD5");
144 } catch (NoSuchAlgorithmException e
) {
145 throw new RuntimeException("JRE lacks MD5 implementation", e
);
149 /** AWSAccessKeyId, public string that identifies the user's account. */
150 private final String publicKey
;
152 /** Decoded form of the private AWSSecretAccessKey, to sign requests. */
153 private final SecretKeySpec privateKey
;
155 /** Our HTTP proxy support, in case we are behind a firewall. */
156 private final ProxySelector proxySelector
;
158 /** ACL to apply to created objects. */
159 private final String acl
;
161 /** Maximum number of times to try an operation. */
162 private final int maxAttempts
;
165 * Create a new S3 client for the supplied user information.
167 * The connection properties are a subset of those supported by the popular
168 * <a href="http://jets3t.s3.amazonaws.com/index.html">jets3t</a> library.
172 * # AWS Access and Secret Keys (required)
173 * accesskey: <YourAWSAccessKey>
174 * secretkey: <YourAWSSecretKey>
176 * # Access Control List setting to apply to uploads, must be one of:
177 * # PRIVATE, PUBLIC_READ (defaults to PRIVATE).
180 * # Number of times to retry after internal error from S3.
181 * httpclient.retry-max: 3
185 * connection properties.
188 public AmazonS3(final Properties props
) {
189 publicKey
= props
.getProperty("accesskey");
190 if (publicKey
== null)
191 throw new IllegalArgumentException("Missing accesskey.");
193 final String secret
= props
.getProperty("secretkey");
195 throw new IllegalArgumentException("Missing secretkey.");
196 privateKey
= new SecretKeySpec(Constants
.encodeASCII(secret
), HMAC
);
198 final String pacl
= props
.getProperty("acl", "PRIVATE");
199 if ("PRIVATE".equalsIgnoreCase(pacl
))
201 else if ("PUBLIC".equalsIgnoreCase(pacl
))
203 else if ("PUBLIC-READ".equalsIgnoreCase(pacl
))
205 else if ("PUBLIC_READ".equalsIgnoreCase(pacl
))
208 throw new IllegalArgumentException("Invalid acl: " + pacl
);
210 maxAttempts
= Integer
.parseInt(props
.getProperty(
211 "httpclient.retry-max", "3"));
212 proxySelector
= ProxySelector
.getDefault();
216 * Get the content of a bucket object.
219 * name of the bucket storing the object.
221 * key of the object within its bucket.
222 * @return connection to stream the content of the object. The request
223 * properties of the connection may not be modified by the caller as
224 * the request parameters have already been signed.
225 * @throws IOException
226 * sending the request was not possible.
228 public URLConnection
get(final String bucket
, final String key
)
230 for (int curAttempt
= 0; curAttempt
< maxAttempts
; curAttempt
++) {
231 final HttpURLConnection c
= open("GET", bucket
, key
);
233 switch (HttpSupport
.response(c
)) {
234 case HttpURLConnection
.HTTP_OK
:
236 case HttpURLConnection
.HTTP_NOT_FOUND
:
237 throw new FileNotFoundException(key
);
238 case HttpURLConnection
.HTTP_INTERNAL_ERROR
:
241 throw error("Reading", key
, c
);
244 throw maxAttempts("Reading", key
);
248 * List the names of keys available within a bucket.
250 * This method is primarily meant for obtaining a "recursive directory
251 * listing" rooted under the specified bucket and prefix location.
254 * name of the bucket whose objects should be listed.
256 * common prefix to filter the results by. Must not be null.
257 * Supplying the empty string will list all keys in the bucket.
258 * Supplying a non-empty string will act as though a trailing '/'
259 * appears in prefix, even if it does not.
260 * @return list of keys starting with <code>prefix</code>, after removing
261 * <code>prefix</code> (or <code>prefix + "/"</code>)from all
263 * @throws IOException
264 * sending the request was not possible, or the response XML
265 * document could not be parsed properly.
267 public List
<String
> list(final String bucket
, String prefix
)
269 if (prefix
.length() > 0 && !prefix
.endsWith("/"))
271 final ListParser lp
= new ListParser(bucket
, prefix
);
274 } while (lp
.truncated
);
279 * Delete a single object.
281 * Deletion always succeeds, even if the object does not exist.
284 * name of the bucket storing the object.
286 * key of the object within its bucket.
287 * @throws IOException
288 * deletion failed due to communications error.
290 public void delete(final String bucket
, final String key
)
292 for (int curAttempt
= 0; curAttempt
< maxAttempts
; curAttempt
++) {
293 final HttpURLConnection c
= open("DELETE", bucket
, key
);
295 switch (HttpSupport
.response(c
)) {
296 case HttpURLConnection
.HTTP_NO_CONTENT
:
298 case HttpURLConnection
.HTTP_INTERNAL_ERROR
:
301 throw error("Deletion", key
, c
);
304 throw maxAttempts("Deletion", key
);
308 * Atomically create or replace a single small object.
310 * This form is only suitable for smaller contents, where the caller can
311 * reasonable fit the entire thing into memory.
313 * End-to-end data integrity is assured by internally computing the MD5
314 * checksum of the supplied data and transmitting the checksum along with
318 * name of the bucket storing the object.
320 * key of the object within its bucket.
322 * new data content for the object. Must not be null. Zero length
323 * array will create a zero length object.
324 * @throws IOException
325 * creation/updating failed due to communications error.
327 public void put(final String bucket
, final String key
, final byte[] data
)
329 final String md5str
= Base64
.encodeBytes(newMD5().digest(data
));
330 final String lenstr
= String
.valueOf(data
.length
);
331 for (int curAttempt
= 0; curAttempt
< maxAttempts
; curAttempt
++) {
332 final HttpURLConnection c
= open("PUT", bucket
, key
);
333 c
.setRequestProperty("Content-Length", lenstr
);
334 c
.setRequestProperty("Content-MD5", md5str
);
335 c
.setRequestProperty(X_AMZ_ACL
, acl
);
338 c
.setFixedLengthStreamingMode(data
.length
);
339 final OutputStream os
= c
.getOutputStream();
346 switch (HttpSupport
.response(c
)) {
347 case HttpURLConnection
.HTTP_OK
:
349 case HttpURLConnection
.HTTP_INTERNAL_ERROR
:
352 throw error("Writing", key
, c
);
355 throw maxAttempts("Writing", key
);
359 * Atomically create or replace a single large object.
361 * Initially the returned output stream buffers data into memory, but if the
362 * total number of written bytes starts to exceed an internal limit the data
363 * is spooled to a temporary file on the local drive.
365 * Network transmission is attempted only when <code>close()</code> gets
366 * called at the end of output. Closing the returned stream can therefore
367 * take significant time, especially if the written content is very large.
369 * End-to-end data integrity is assured by internally computing the MD5
370 * checksum of the supplied data and transmitting the checksum along with
374 * name of the bucket storing the object.
376 * key of the object within its bucket.
377 * @return a stream which accepts the new data, and transmits once closed.
379 public OutputStream
beginPut(final String bucket
, final String key
) {
380 final MessageDigest md5
= newMD5();
381 final TemporaryBuffer buffer
= new TemporaryBuffer() {
383 public void close() throws IOException
{
386 putImpl(bucket
, key
, md5
.digest(), this);
392 return new DigestOutputStream(buffer
, md5
);
395 private void putImpl(final String bucket
, final String key
,
396 final byte[] csum
, final TemporaryBuffer buf
) throws IOException
{
397 final String md5str
= Base64
.encodeBytes(csum
);
398 final long len
= buf
.length();
399 final String lenstr
= String
.valueOf(len
);
400 for (int curAttempt
= 0; curAttempt
< maxAttempts
; curAttempt
++) {
401 final HttpURLConnection c
= open("PUT", bucket
, key
);
402 c
.setRequestProperty("Content-Length", lenstr
);
403 c
.setRequestProperty("Content-MD5", md5str
);
404 c
.setRequestProperty(X_AMZ_ACL
, acl
);
407 c
.setFixedLengthStreamingMode((int) len
);
408 final OutputStream os
= c
.getOutputStream();
410 buf
.writeTo(os
, null);
415 switch (HttpSupport
.response(c
)) {
416 case HttpURLConnection
.HTTP_OK
:
418 case HttpURLConnection
.HTTP_INTERNAL_ERROR
:
421 throw error("Writing", key
, c
);
424 throw maxAttempts("Writing", key
);
427 private IOException
error(final String action
, final String key
,
428 final HttpURLConnection c
) throws IOException
{
429 return new IOException(action
+ " of '" + key
+ "' failed: "
430 + HttpSupport
.response(c
) + " " + c
.getResponseMessage());
433 private IOException
maxAttempts(final String action
, final String key
) {
434 return new IOException(action
+ " of '" + key
+ "' failed:"
435 + " Giving up after " + maxAttempts
+ " attempts.");
438 private HttpURLConnection
open(final String method
, final String bucket
,
439 final String key
) throws IOException
{
440 final Map
<String
, String
> noArgs
= Collections
.emptyMap();
441 return open(method
, bucket
, key
, noArgs
);
444 private HttpURLConnection
open(final String method
, final String bucket
,
445 final String key
, final Map
<String
, String
> args
)
447 final StringBuilder urlstr
= new StringBuilder();
448 urlstr
.append("http://");
449 urlstr
.append(bucket
);
451 urlstr
.append(DOMAIN
);
453 if (key
.length() > 0)
454 HttpSupport
.encode(urlstr
, key
);
455 if (!args
.isEmpty()) {
456 final Iterator
<Map
.Entry
<String
, String
>> i
;
459 i
= args
.entrySet().iterator();
460 while (i
.hasNext()) {
461 final Map
.Entry
<String
, String
> e
= i
.next();
462 urlstr
.append(e
.getKey());
464 HttpSupport
.encode(urlstr
, e
.getValue());
470 final URL url
= new URL(urlstr
.toString());
471 final Proxy proxy
= HttpSupport
.proxyFor(proxySelector
, url
);
472 final HttpURLConnection c
;
474 c
= (HttpURLConnection
) url
.openConnection(proxy
);
475 c
.setRequestMethod(method
);
476 c
.setRequestProperty("User-Agent", "jgit/1.0");
477 c
.setRequestProperty("Date", httpNow());
481 private void authorize(final HttpURLConnection c
) throws IOException
{
482 final Map
<String
, List
<String
>> reqHdr
= c
.getRequestProperties();
483 final SortedMap
<String
, String
> sigHdr
= new TreeMap
<String
, String
>();
484 for (final String hdr
: reqHdr
.keySet()) {
485 if (isSignedHeader(hdr
))
486 sigHdr
.put(hdr
.toLowerCase(), toCleanString(reqHdr
.get(hdr
)));
489 final StringBuilder s
= new StringBuilder();
490 s
.append(c
.getRequestMethod());
493 s
.append(remove(sigHdr
, "content-md5"));
496 s
.append(remove(sigHdr
, "content-type"));
499 s
.append(remove(sigHdr
, "date"));
502 for (final Map
.Entry
<String
, String
> e
: sigHdr
.entrySet()) {
503 s
.append(e
.getKey());
505 s
.append(e
.getValue());
509 final String host
= c
.getURL().getHost();
511 s
.append(host
.substring(0, host
.length() - DOMAIN
.length() - 1));
512 s
.append(c
.getURL().getPath());
516 final Mac m
= Mac
.getInstance(HMAC
);
518 sec
= Base64
.encodeBytes(m
.doFinal(s
.toString().getBytes("UTF-8")));
519 } catch (NoSuchAlgorithmException e
) {
520 throw new IOException("No " + HMAC
+ " support:" + e
.getMessage());
521 } catch (InvalidKeyException e
) {
522 throw new IOException("Invalid key: " + e
.getMessage());
524 c
.setRequestProperty("Authorization", "AWS " + publicKey
+ ":" + sec
);
528 * Simple command line interface to {@link AmazonS3}.
531 * command line arguments. See usage for details.
532 * @throws IOException
535 public static void main(final String
[] argv
) throws IOException
{
536 if (argv
.length
!= 4) {
541 AwtAuthenticator
.install();
542 HttpSupport
.configureHttpProxy();
544 final AmazonS3 s3
= new AmazonS3(properties(new File(argv
[0])));
545 final String op
= argv
[1];
546 final String bucket
= argv
[2];
547 final String key
= argv
[3];
548 if ("get".equals(op
)) {
549 final URLConnection c
= s3
.get(bucket
, key
);
550 int len
= c
.getContentLength();
551 final InputStream in
= c
.getInputStream();
553 final byte[] tmp
= new byte[2048];
555 final int n
= in
.read(tmp
);
557 throw new EOFException("Expected " + len
+ " bytes.");
558 System
.out
.write(tmp
, 0, n
);
564 } else if ("ls".equals(op
) || "list".equals(op
)) {
565 for (final String k
: s3
.list(bucket
, key
))
566 System
.out
.println(k
);
567 } else if ("rm".equals(op
) || "delete".equals(op
)) {
568 s3
.delete(bucket
, key
);
569 } else if ("put".equals(op
)) {
570 final OutputStream os
= s3
.beginPut(bucket
, key
);
571 final byte[] tmp
= new byte[2048];
573 while ((n
= System
.in
.read(tmp
)) > 0)
581 private static void commandLineUsage() {
582 System
.err
.println("usage: conn.prop op bucket key");
583 System
.err
.println();
584 System
.err
.println(" where conn.prop is a jets3t properties file.");
585 System
.err
.println(" op is one of: get ls rm put");
586 System
.err
.println(" bucket is the name of the S3 bucket");
587 System
.err
.println(" key is the name of the object.");
591 static Properties
properties(final File authFile
)
592 throws FileNotFoundException
, IOException
{
593 final Properties p
= new Properties();
594 final FileInputStream in
= new FileInputStream(authFile
);
603 private final class ListParser
extends DefaultHandler
{
604 final List
<String
> entries
= new ArrayList
<String
>();
606 private final String bucket
;
608 private final String prefix
;
612 private StringBuilder data
;
614 ListParser(final String bn
, final String p
) {
619 void list() throws IOException
{
620 final Map
<String
, String
> args
= new TreeMap
<String
, String
>();
621 if (prefix
.length() > 0)
622 args
.put("prefix", prefix
);
623 if (!entries
.isEmpty())
624 args
.put("marker", prefix
+ entries
.get(entries
.size() - 1));
626 for (int curAttempt
= 0; curAttempt
< maxAttempts
; curAttempt
++) {
627 final HttpURLConnection c
= open("GET", bucket
, "", args
);
629 switch (HttpSupport
.response(c
)) {
630 case HttpURLConnection
.HTTP_OK
:
636 xr
= XMLReaderFactory
.createXMLReader();
637 } catch (SAXException e
) {
638 throw new IOException("No XML parser available.");
640 xr
.setContentHandler(this);
641 final InputStream in
= c
.getInputStream();
643 xr
.parse(new InputSource(in
));
644 } catch (SAXException parsingError
) {
646 p
= new IOException("Error listing " + prefix
);
647 p
.initCause(parsingError
);
654 case HttpURLConnection
.HTTP_INTERNAL_ERROR
:
658 throw AmazonS3
.this.error("Listing", prefix
, c
);
661 throw maxAttempts("Listing", prefix
);
665 public void startElement(final String uri
, final String name
,
666 final String qName
, final Attributes attributes
)
667 throws SAXException
{
668 if ("Key".equals(name
) || "IsTruncated".equals(name
))
669 data
= new StringBuilder();
673 public void ignorableWhitespace(final char[] ch
, final int s
,
674 final int n
) throws SAXException
{
676 data
.append(ch
, s
, n
);
680 public void characters(final char[] ch
, final int s
, final int n
)
681 throws SAXException
{
683 data
.append(ch
, s
, n
);
687 public void endElement(final String uri
, final String name
,
688 final String qName
) throws SAXException
{
689 if ("Key".equals(name
))
690 entries
.add(data
.toString().substring(prefix
.length()));
691 else if ("IsTruncated".equals(name
))
692 truncated
= "true".equalsIgnoreCase(data
.toString());