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 try (OutputStream os = beginPut(bucket, key, null, null)) {
406 os.write(data);
407 }
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 try (OutputStream os = c.getOutputStream()) {
422 os.write(data);
423 }
424
425 switch (HttpSupport.response(c)) {
426 case HttpURLConnection.HTTP_OK:
427 return;
428 case HttpURLConnection.HTTP_INTERNAL_ERROR:
429 continue;
430 default:
431 throw error(JGitText.get().s3ActionWriting, key, c);
432 }
433 }
434 throw maxAttempts(JGitText.get().s3ActionWriting, key);
435 }
436
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 public OutputStream beginPut(final String bucket, final String key,
466 final ProgressMonitor monitor, final String monitorTask)
467 throws IOException {
468 final MessageDigest md5 = newMD5();
469 final TemporaryBuffer buffer = new TemporaryBuffer.LocalFile(tmpDir) {
470 @Override
471 public void close() throws IOException {
472 super.close();
473 try {
474 putImpl(bucket, key, md5.digest(), this, monitor,
475 monitorTask);
476 } finally {
477 destroy();
478 }
479 }
480 };
481 return encryption.encrypt(new DigestOutputStream(buffer, md5));
482 }
483
484 void putImpl(final String bucket, final String key,
485 final byte[] csum, final TemporaryBuffer buf,
486 ProgressMonitor monitor, String monitorTask) throws IOException {
487 if (monitor == null)
488 monitor = NullProgressMonitor.INSTANCE;
489 if (monitorTask == null)
490 monitorTask = MessageFormat.format(JGitText.get().progressMonUploading, key);
491
492 final String md5str = Base64.encodeBytes(csum);
493 final long len = buf.length();
494 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
495 final HttpURLConnection c = open("PUT", bucket, key);
496 c.setFixedLengthStreamingMode(len);
497 c.setRequestProperty("Content-MD5", md5str);
498 c.setRequestProperty(X_AMZ_ACL, acl);
499 encryption.request(c, X_AMZ_META);
500 authorize(c);
501 c.setDoOutput(true);
502 monitor.beginTask(monitorTask, (int) (len / 1024));
503 try (OutputStream os = c.getOutputStream()) {
504 buf.writeTo(os, monitor);
505 } finally {
506 monitor.endTask();
507 }
508
509 switch (HttpSupport.response(c)) {
510 case HttpURLConnection.HTTP_OK:
511 return;
512 case HttpURLConnection.HTTP_INTERNAL_ERROR:
513 continue;
514 default:
515 throw error(JGitText.get().s3ActionWriting, key, c);
516 }
517 }
518 throw maxAttempts(JGitText.get().s3ActionWriting, key);
519 }
520
521 IOException error(final String action, final String key,
522 final HttpURLConnection c) throws IOException {
523 final IOException err = new IOException(MessageFormat.format(
524 JGitText.get().amazonS3ActionFailed, action, key,
525 Integer.valueOf(HttpSupport.response(c)),
526 c.getResponseMessage()));
527 final InputStream errorStream = c.getErrorStream();
528 if (errorStream == null) {
529 return err;
530 }
531
532 try {
533 final ByteArrayOutputStream b = new ByteArrayOutputStream();
534 byte[] buf = new byte[2048];
535 for (;;) {
536 final int n = errorStream.read(buf);
537 if (n < 0) {
538 break;
539 }
540 if (n > 0) {
541 b.write(buf, 0, n);
542 }
543 }
544 buf = b.toByteArray();
545 if (buf.length > 0) {
546 err.initCause(new IOException("\n" + new String(buf)));
547 }
548 } finally {
549 errorStream.close();
550 }
551 return err;
552 }
553
554 IOException maxAttempts(final String action, final String key) {
555 return new IOException(MessageFormat.format(
556 JGitText.get().amazonS3ActionFailedGivingUp, action, key,
557 Integer.valueOf(maxAttempts)));
558 }
559
560 private HttpURLConnection open(final String method, final String bucket,
561 final String key) throws IOException {
562 final Map<String, String> noArgs = Collections.emptyMap();
563 return open(method, bucket, key, noArgs);
564 }
565
566 HttpURLConnection open(final String method, final String bucket,
567 final String key, final Map<String, String> args)
568 throws IOException {
569 final StringBuilder urlstr = new StringBuilder();
570 urlstr.append("http://"); //$NON-NLS-1$
571 urlstr.append(bucket);
572 urlstr.append('.');
573 urlstr.append(domain);
574 urlstr.append('/');
575 if (key.length() > 0)
576 HttpSupport.encode(urlstr, key);
577 if (!args.isEmpty()) {
578 final Iterator<Map.Entry<String, String>> i;
579
580 urlstr.append('?');
581 i = args.entrySet().iterator();
582 while (i.hasNext()) {
583 final Map.Entry<String, String> e = i.next();
584 urlstr.append(e.getKey());
585 urlstr.append('=');
586 HttpSupport.encode(urlstr, e.getValue());
587 if (i.hasNext())
588 urlstr.append('&');
589 }
590 }
591
592 final URL url = new URL(urlstr.toString());
593 final Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
594 final HttpURLConnection c;
595
596 c = (HttpURLConnection) url.openConnection(proxy);
597 c.setRequestMethod(method);
598 c.setRequestProperty("User-Agent", "jgit/1.0");
599 c.setRequestProperty("Date", httpNow());
600 return c;
601 }
602
603 void authorize(final HttpURLConnection c) throws IOException {
604 final Map<String, List<String>> reqHdr = c.getRequestProperties();
605 final SortedMap<String, String> sigHdr = new TreeMap<>();
606 for (final Map.Entry<String, List<String>> entry : reqHdr.entrySet()) {
607 final String hdr = entry.getKey();
608 if (isSignedHeader(hdr))
609 sigHdr.put(StringUtils.toLowerCase(hdr), toCleanString(entry.getValue()));
610 }
611
612 final StringBuilder s = new StringBuilder();
613 s.append(c.getRequestMethod());
614 s.append('\n');
615
616 s.append(remove(sigHdr, "content-md5"));
617 s.append('\n');
618
619 s.append(remove(sigHdr, "content-type"));
620 s.append('\n');
621
622 s.append(remove(sigHdr, "date"));
623 s.append('\n');
624
625 for (final Map.Entry<String, String> e : sigHdr.entrySet()) {
626 s.append(e.getKey());
627 s.append(':');
628 s.append(e.getValue());
629 s.append('\n');
630 }
631
632 final String host = c.getURL().getHost();
633 s.append('/');
634 s.append(host.substring(0, host.length() - domain.length() - 1));
635 s.append(c.getURL().getPath());
636
637 final String sec;
638 try {
639 final Mac m = Mac.getInstance(HMAC);
640 m.init(privateKey);
641 sec = Base64.encodeBytes(m.doFinal(s.toString().getBytes(UTF_8)));
642 } catch (NoSuchAlgorithmException e) {
643 throw new IOException(MessageFormat.format(JGitText.get().noHMACsupport, HMAC, e.getMessage()));
644 } catch (InvalidKeyException e) {
645 throw new IOException(MessageFormat.format(JGitText.get().invalidKey, e.getMessage()));
646 }
647 c.setRequestProperty("Authorization", "AWS " + publicKey + ":" + sec);
648 }
649
650 static Properties properties(final File authFile)
651 throws FileNotFoundException, IOException {
652 final Properties p = new Properties();
653 try (FileInputStream in = new FileInputStream(authFile)) {
654 p.load(in);
655 }
656 return p;
657 }
658
659 private final class ListParser extends DefaultHandler {
660 final List<String> entries = new ArrayList<>();
661
662 private final String bucket;
663
664 private final String prefix;
665
666 boolean truncated;
667
668 private StringBuilder data;
669
670 ListParser(final String bn, final String p) {
671 bucket = bn;
672 prefix = p;
673 }
674
675 void list() throws IOException {
676 final Map<String, String> args = new TreeMap<>();
677 if (prefix.length() > 0)
678 args.put("prefix", prefix);
679 if (!entries.isEmpty())
680 args.put("marker", prefix + entries.get(entries.size() - 1));
681
682 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
683 final HttpURLConnection c = open("GET", bucket, "", args);
684 authorize(c);
685 switch (HttpSupport.response(c)) {
686 case HttpURLConnection.HTTP_OK:
687 truncated = false;
688 data = null;
689
690 final XMLReader xr;
691 try {
692 xr = XMLReaderFactory.createXMLReader();
693 } catch (SAXException e) {
694 throw new IOException(JGitText.get().noXMLParserAvailable);
695 }
696 xr.setContentHandler(this);
697 try (InputStream in = c.getInputStream()) {
698 xr.parse(new InputSource(in));
699 } catch (SAXException parsingError) {
700 throw new IOException(
701 MessageFormat.format(
702 JGitText.get().errorListing, prefix),
703 parsingError);
704 }
705 return;
706
707 case HttpURLConnection.HTTP_INTERNAL_ERROR:
708 continue;
709
710 default:
711 throw AmazonS3.this.error("Listing", prefix, c);
712 }
713 }
714 throw maxAttempts("Listing", prefix);
715 }
716
717 @Override
718 public void startElement(final String uri, final String name,
719 final String qName, final Attributes attributes)
720 throws SAXException {
721 if ("Key".equals(name) || "IsTruncated".equals(name))
722 data = new StringBuilder();
723 }
724
725 @Override
726 public void ignorableWhitespace(final char[] ch, final int s,
727 final int n) throws SAXException {
728 if (data != null)
729 data.append(ch, s, n);
730 }
731
732 @Override
733 public void characters(final char[] ch, final int s, final int n)
734 throws SAXException {
735 if (data != null)
736 data.append(ch, s, n);
737 }
738
739 @Override
740 public void endElement(final String uri, final String name,
741 final String qName) throws SAXException {
742 if ("Key".equals(name))
743 entries.add(data.toString().substring(prefix.length()));
744 else if ("IsTruncated".equals(name))
745 truncated = StringUtils.equalsIgnoreCase("true", data.toString());
746 data = null;
747 }
748 }
749 }