1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44 package org.eclipse.jgit.transport;
45
46 import java.io.ByteArrayOutputStream;
47 import java.io.File;
48 import java.io.FileInputStream;
49 import java.io.FileNotFoundException;
50 import java.io.IOException;
51 import java.io.InputStream;
52 import java.io.OutputStream;
53 import java.net.HttpURLConnection;
54 import java.net.Proxy;
55 import java.net.ProxySelector;
56 import java.net.URL;
57 import java.net.URLConnection;
58 import java.security.DigestOutputStream;
59 import java.security.GeneralSecurityException;
60 import java.security.InvalidKeyException;
61 import java.security.MessageDigest;
62 import java.security.NoSuchAlgorithmException;
63 import java.text.MessageFormat;
64 import java.text.SimpleDateFormat;
65 import java.util.ArrayList;
66 import java.util.Collections;
67 import java.util.Date;
68 import java.util.HashSet;
69 import java.util.Iterator;
70 import java.util.List;
71 import java.util.Locale;
72 import java.util.Map;
73 import java.util.Properties;
74 import java.util.Set;
75 import java.util.SortedMap;
76 import java.util.TimeZone;
77 import java.util.TreeMap;
78
79 import javax.crypto.Mac;
80 import javax.crypto.spec.SecretKeySpec;
81
82 import org.eclipse.jgit.internal.JGitText;
83 import org.eclipse.jgit.lib.Constants;
84 import org.eclipse.jgit.lib.NullProgressMonitor;
85 import org.eclipse.jgit.lib.ProgressMonitor;
86 import org.eclipse.jgit.util.Base64;
87 import org.eclipse.jgit.util.HttpSupport;
88 import org.eclipse.jgit.util.StringUtils;
89 import org.eclipse.jgit.util.TemporaryBuffer;
90 import org.xml.sax.Attributes;
91 import org.xml.sax.InputSource;
92 import org.xml.sax.SAXException;
93 import org.xml.sax.XMLReader;
94 import org.xml.sax.helpers.DefaultHandler;
95 import org.xml.sax.helpers.XMLReaderFactory;
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113 public class AmazonS3 {
114 private static final Set<String> SIGNED_HEADERS;
115
116 private static final String HMAC = "HmacSHA1";
117
118 private static final String X_AMZ_ACL = "x-amz-acl";
119
120 private static final String X_AMZ_META = "x-amz-meta-";
121
122 static {
123 SIGNED_HEADERS = new HashSet<String>();
124 SIGNED_HEADERS.add("content-type");
125 SIGNED_HEADERS.add("content-md5");
126 SIGNED_HEADERS.add("date");
127 }
128
129 private static boolean isSignedHeader(final String name) {
130 final String nameLC = StringUtils.toLowerCase(name);
131 return SIGNED_HEADERS.contains(nameLC) || nameLC.startsWith("x-amz-");
132 }
133
134 private static String toCleanString(final List<String> list) {
135 final StringBuilder s = new StringBuilder();
136 for (final String v : list) {
137 if (s.length() > 0)
138 s.append(',');
139 s.append(v.replaceAll("\n", "").trim());
140 }
141 return s.toString();
142 }
143
144 private static String remove(final Map<String, String> m, final String k) {
145 final String r = m.remove(k);
146 return r != null ? r : "";
147 }
148
149 private static String httpNow() {
150 final String tz = "GMT";
151 final SimpleDateFormat fmt;
152 fmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
153 fmt.setTimeZone(TimeZone.getTimeZone(tz));
154 return fmt.format(new Date()) + " " + tz;
155 }
156
157 private static MessageDigest newMD5() {
158 try {
159 return MessageDigest.getInstance("MD5");
160 } catch (NoSuchAlgorithmException e) {
161 throw new RuntimeException(JGitText.get().JRELacksMD5Implementation, e);
162 }
163 }
164
165
166 private final String publicKey;
167
168
169 private final SecretKeySpec privateKey;
170
171
172 private final ProxySelector proxySelector;
173
174
175 private final String acl;
176
177
178 final int maxAttempts;
179
180
181 private final WalkEncryption encryption;
182
183
184 private final File tmpDir;
185
186
187 private final String domain;
188
189
190 interface Keys {
191 String ACCESS_KEY = "accesskey";
192 String SECRET_KEY = "secretkey";
193 String PASSWORD = "password";
194 String CRYPTO_ALG = "crypto.algorithm";
195 String CRYPTO_VER = "crypto.version";
196 String ACL = "acl";
197 String DOMAIN = "domain";
198 String HTTP_RETRY = "httpclient.retry-max";
199 String TMP_DIR = "tmpdir";
200 }
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234 public AmazonS3(final Properties props) {
235 domain = props.getProperty(Keys.DOMAIN, "s3.amazonaws.com");
236
237 publicKey = props.getProperty(Keys.ACCESS_KEY);
238 if (publicKey == null)
239 throw new IllegalArgumentException(JGitText.get().missingAccesskey);
240
241 final String secret = props.getProperty(Keys.SECRET_KEY);
242 if (secret == null)
243 throw new IllegalArgumentException(JGitText.get().missingSecretkey);
244 privateKey = new SecretKeySpec(Constants.encodeASCII(secret), HMAC);
245
246 final String pacl = props.getProperty(Keys.ACL, "PRIVATE");
247 if (StringUtils.equalsIgnoreCase("PRIVATE", pacl))
248 acl = "private";
249 else if (StringUtils.equalsIgnoreCase("PUBLIC", pacl))
250 acl = "public-read";
251 else if (StringUtils.equalsIgnoreCase("PUBLIC-READ", pacl))
252 acl = "public-read";
253 else if (StringUtils.equalsIgnoreCase("PUBLIC_READ", pacl))
254 acl = "public-read";
255 else
256 throw new IllegalArgumentException("Invalid acl: " + pacl);
257
258 try {
259 encryption = WalkEncryption.instance(props);
260 } catch (GeneralSecurityException e) {
261 throw new IllegalArgumentException(JGitText.get().invalidEncryption, e);
262 }
263
264 maxAttempts = Integer
265 .parseInt(props.getProperty(Keys.HTTP_RETRY, "3"));
266 proxySelector = ProxySelector.getDefault();
267
268 String tmp = props.getProperty(Keys.TMP_DIR);
269 tmpDir = tmp != null && tmp.length() > 0 ? new File(tmp) : null;
270 }
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285 public URLConnection get(final String bucket, final String key)
286 throws IOException {
287 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
288 final HttpURLConnection c = open("GET", bucket, key);
289 authorize(c);
290 switch (HttpSupport.response(c)) {
291 case HttpURLConnection.HTTP_OK:
292 encryption.validate(c, X_AMZ_META);
293 return c;
294 case HttpURLConnection.HTTP_NOT_FOUND:
295 throw new FileNotFoundException(key);
296 case HttpURLConnection.HTTP_INTERNAL_ERROR:
297 continue;
298 default:
299 throw error(JGitText.get().s3ActionReading, key, c);
300 }
301 }
302 throw maxAttempts(JGitText.get().s3ActionReading, key);
303 }
304
305
306
307
308
309
310
311
312
313
314 public InputStream decrypt(final URLConnection u) throws IOException {
315 return encryption.decrypt(u.getInputStream());
316 }
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338 public List<String> list(final String bucket, String prefix)
339 throws IOException {
340 if (prefix.length() > 0 && !prefix.endsWith("/"))
341 prefix += "/";
342 final ListParser lp = new ListParser(bucket, prefix);
343 do {
344 lp.list();
345 } while (lp.truncated);
346 return lp.entries;
347 }
348
349
350
351
352
353
354
355
356
357
358
359
360
361 public void delete(final String bucket, final String key)
362 throws IOException {
363 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
364 final HttpURLConnection c = open("DELETE", bucket, key);
365 authorize(c);
366 switch (HttpSupport.response(c)) {
367 case HttpURLConnection.HTTP_NO_CONTENT:
368 return;
369 case HttpURLConnection.HTTP_INTERNAL_ERROR:
370 continue;
371 default:
372 throw error(JGitText.get().s3ActionDeletion, key, c);
373 }
374 }
375 throw maxAttempts(JGitText.get().s3ActionDeletion, key);
376 }
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398 public void put(final String bucket, final String key, final byte[] data)
399 throws IOException {
400 if (encryption != WalkEncryption.NONE) {
401
402
403
404 final OutputStream os = beginPut(bucket, key, null, null);
405 os.write(data);
406 os.close();
407 return;
408 }
409
410 final String md5str = Base64.encodeBytes(newMD5().digest(data));
411 final String lenstr = String.valueOf(data.length);
412 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
413 final HttpURLConnection c = open("PUT", bucket, key);
414 c.setRequestProperty("Content-Length", lenstr);
415 c.setRequestProperty("Content-MD5", md5str);
416 c.setRequestProperty(X_AMZ_ACL, acl);
417 authorize(c);
418 c.setDoOutput(true);
419 c.setFixedLengthStreamingMode(data.length);
420 final OutputStream os = c.getOutputStream();
421 try {
422 os.write(data);
423 } finally {
424 os.close();
425 }
426
427 switch (HttpSupport.response(c)) {
428 case HttpURLConnection.HTTP_OK:
429 return;
430 case HttpURLConnection.HTTP_INTERNAL_ERROR:
431 continue;
432 default:
433 throw error(JGitText.get().s3ActionWriting, key, c);
434 }
435 }
436 throw maxAttempts(JGitText.get().s3ActionWriting, key);
437 }
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467 public OutputStream beginPut(final String bucket, final String key,
468 final ProgressMonitor monitor, final String monitorTask)
469 throws IOException {
470 final MessageDigest md5 = newMD5();
471 final TemporaryBuffer buffer = new TemporaryBuffer.LocalFile(tmpDir) {
472 @Override
473 public void close() throws IOException {
474 super.close();
475 try {
476 putImpl(bucket, key, md5.digest(), this, monitor,
477 monitorTask);
478 } finally {
479 destroy();
480 }
481 }
482 };
483 return encryption.encrypt(new DigestOutputStream(buffer, md5));
484 }
485
486 void putImpl(final String bucket, final String key,
487 final byte[] csum, final TemporaryBuffer buf,
488 ProgressMonitor monitor, String monitorTask) throws IOException {
489 if (monitor == null)
490 monitor = NullProgressMonitor.INSTANCE;
491 if (monitorTask == null)
492 monitorTask = MessageFormat.format(JGitText.get().progressMonUploading, key);
493
494 final String md5str = Base64.encodeBytes(csum);
495 final long len = buf.length();
496 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
497 final HttpURLConnection c = open("PUT", bucket, key);
498 c.setFixedLengthStreamingMode(len);
499 c.setRequestProperty("Content-MD5", md5str);
500 c.setRequestProperty(X_AMZ_ACL, acl);
501 encryption.request(c, X_AMZ_META);
502 authorize(c);
503 c.setDoOutput(true);
504 monitor.beginTask(monitorTask, (int) (len / 1024));
505 final OutputStream os = c.getOutputStream();
506 try {
507 buf.writeTo(os, monitor);
508 } finally {
509 monitor.endTask();
510 os.close();
511 }
512
513 switch (HttpSupport.response(c)) {
514 case HttpURLConnection.HTTP_OK:
515 return;
516 case HttpURLConnection.HTTP_INTERNAL_ERROR:
517 continue;
518 default:
519 throw error(JGitText.get().s3ActionWriting, key, c);
520 }
521 }
522 throw maxAttempts(JGitText.get().s3ActionWriting, key);
523 }
524
525 IOException error(final String action, final String key,
526 final HttpURLConnection c) throws IOException {
527 final IOException err = new IOException(MessageFormat.format(
528 JGitText.get().amazonS3ActionFailed, action, key,
529 Integer.valueOf(HttpSupport.response(c)),
530 c.getResponseMessage()));
531 final InputStream errorStream = c.getErrorStream();
532 if (errorStream == null)
533 return err;
534
535 final ByteArrayOutputStream b = new ByteArrayOutputStream();
536 byte[] buf = new byte[2048];
537 for (;;) {
538 final int n = errorStream.read(buf);
539 if (n < 0)
540 break;
541 if (n > 0)
542 b.write(buf, 0, n);
543 }
544 buf = b.toByteArray();
545 if (buf.length > 0)
546 err.initCause(new IOException("\n" + new String(buf)));
547 return err;
548 }
549
550 IOException maxAttempts(final String action, final String key) {
551 return new IOException(MessageFormat.format(
552 JGitText.get().amazonS3ActionFailedGivingUp, action, key,
553 Integer.valueOf(maxAttempts)));
554 }
555
556 private HttpURLConnection open(final String method, final String bucket,
557 final String key) throws IOException {
558 final Map<String, String> noArgs = Collections.emptyMap();
559 return open(method, bucket, key, noArgs);
560 }
561
562 HttpURLConnection open(final String method, final String bucket,
563 final String key, final Map<String, String> args)
564 throws IOException {
565 final StringBuilder urlstr = new StringBuilder();
566 urlstr.append("http://"); //$NON-NLS-1$
567 urlstr.append(bucket);
568 urlstr.append('.');
569 urlstr.append(domain);
570 urlstr.append('/');
571 if (key.length() > 0)
572 HttpSupport.encode(urlstr, key);
573 if (!args.isEmpty()) {
574 final Iterator<Map.Entry<String, String>> i;
575
576 urlstr.append('?');
577 i = args.entrySet().iterator();
578 while (i.hasNext()) {
579 final Map.Entry<String, String> e = i.next();
580 urlstr.append(e.getKey());
581 urlstr.append('=');
582 HttpSupport.encode(urlstr, e.getValue());
583 if (i.hasNext())
584 urlstr.append('&');
585 }
586 }
587
588 final URL url = new URL(urlstr.toString());
589 final Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
590 final HttpURLConnection c;
591
592 c = (HttpURLConnection) url.openConnection(proxy);
593 c.setRequestMethod(method);
594 c.setRequestProperty("User-Agent", "jgit/1.0");
595 c.setRequestProperty("Date", httpNow());
596 return c;
597 }
598
599 void authorize(final HttpURLConnection c) throws IOException {
600 final Map<String, List<String>> reqHdr = c.getRequestProperties();
601 final SortedMap<String, String> sigHdr = new TreeMap<String, String>();
602 for (final Map.Entry<String, List<String>> entry : reqHdr.entrySet()) {
603 final String hdr = entry.getKey();
604 if (isSignedHeader(hdr))
605 sigHdr.put(StringUtils.toLowerCase(hdr), toCleanString(entry.getValue()));
606 }
607
608 final StringBuilder s = new StringBuilder();
609 s.append(c.getRequestMethod());
610 s.append('\n');
611
612 s.append(remove(sigHdr, "content-md5"));
613 s.append('\n');
614
615 s.append(remove(sigHdr, "content-type"));
616 s.append('\n');
617
618 s.append(remove(sigHdr, "date"));
619 s.append('\n');
620
621 for (final Map.Entry<String, String> e : sigHdr.entrySet()) {
622 s.append(e.getKey());
623 s.append(':');
624 s.append(e.getValue());
625 s.append('\n');
626 }
627
628 final String host = c.getURL().getHost();
629 s.append('/');
630 s.append(host.substring(0, host.length() - domain.length() - 1));
631 s.append(c.getURL().getPath());
632
633 final String sec;
634 try {
635 final Mac m = Mac.getInstance(HMAC);
636 m.init(privateKey);
637 sec = Base64.encodeBytes(m.doFinal(s.toString().getBytes("UTF-8")));
638 } catch (NoSuchAlgorithmException e) {
639 throw new IOException(MessageFormat.format(JGitText.get().noHMACsupport, HMAC, e.getMessage()));
640 } catch (InvalidKeyException e) {
641 throw new IOException(MessageFormat.format(JGitText.get().invalidKey, e.getMessage()));
642 }
643 c.setRequestProperty("Authorization", "AWS " + publicKey + ":" + sec);
644 }
645
646 static Properties properties(final File authFile)
647 throws FileNotFoundException, IOException {
648 final Properties p = new Properties();
649 final FileInputStream in = new FileInputStream(authFile);
650 try {
651 p.load(in);
652 } finally {
653 in.close();
654 }
655 return p;
656 }
657
658 private final class ListParser extends DefaultHandler {
659 final List<String> entries = new ArrayList<String>();
660
661 private final String bucket;
662
663 private final String prefix;
664
665 boolean truncated;
666
667 private StringBuilder data;
668
669 ListParser(final String bn, final String p) {
670 bucket = bn;
671 prefix = p;
672 }
673
674 void list() throws IOException {
675 final Map<String, String> args = new TreeMap<String, String>();
676 if (prefix.length() > 0)
677 args.put("prefix", prefix);
678 if (!entries.isEmpty())
679 args.put("marker", prefix + entries.get(entries.size() - 1));
680
681 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
682 final HttpURLConnection c = open("GET", bucket, "", args);
683 authorize(c);
684 switch (HttpSupport.response(c)) {
685 case HttpURLConnection.HTTP_OK:
686 truncated = false;
687 data = null;
688
689 final XMLReader xr;
690 try {
691 xr = XMLReaderFactory.createXMLReader();
692 } catch (SAXException e) {
693 throw new IOException(JGitText.get().noXMLParserAvailable);
694 }
695 xr.setContentHandler(this);
696 final InputStream in = c.getInputStream();
697 try {
698 xr.parse(new InputSource(in));
699 } catch (SAXException parsingError) {
700 final IOException p;
701 p = new IOException(MessageFormat.format(JGitText.get().errorListing, prefix));
702 p.initCause(parsingError);
703 throw p;
704 } finally {
705 in.close();
706 }
707 return;
708
709 case HttpURLConnection.HTTP_INTERNAL_ERROR:
710 continue;
711
712 default:
713 throw AmazonS3.this.error("Listing", prefix, c);
714 }
715 }
716 throw maxAttempts("Listing", prefix);
717 }
718
719 @Override
720 public void startElement(final String uri, final String name,
721 final String qName, final Attributes attributes)
722 throws SAXException {
723 if ("Key".equals(name) || "IsTruncated".equals(name))
724 data = new StringBuilder();
725 }
726
727 @Override
728 public void ignorableWhitespace(final char[] ch, final int s,
729 final int n) throws SAXException {
730 if (data != null)
731 data.append(ch, s, n);
732 }
733
734 @Override
735 public void characters(final char[] ch, final int s, final int n)
736 throws SAXException {
737 if (data != null)
738 data.append(ch, s, n);
739 }
740
741 @Override
742 public void endElement(final String uri, final String name,
743 final String qName) throws SAXException {
744 if ("Key".equals(name))
745 entries.add(data.toString().substring(prefix.length()));
746 else if ("IsTruncated".equals(name))
747 truncated = StringUtils.equalsIgnoreCase("true", data.toString());
748 data = null;
749 }
750 }
751 }