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