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(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(List<String> list) {
137 final StringBuilder s = new StringBuilder();
138 for (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(Map<String, String> m, 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(String bucket, 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(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(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(String bucket, 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(String bucket, String key, 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 if (c.getErrorStream() == null) {
528 return err;
529 }
530
531 try (InputStream errorStream = c.getErrorStream()) {
532 final ByteArrayOutputStream b = new ByteArrayOutputStream();
533 byte[] buf = new byte[2048];
534 for (;;) {
535 final int n = errorStream.read(buf);
536 if (n < 0) {
537 break;
538 }
539 if (n > 0) {
540 b.write(buf, 0, n);
541 }
542 }
543 buf = b.toByteArray();
544 if (buf.length > 0) {
545 err.initCause(new IOException("\n" + new String(buf, UTF_8)));
546 }
547 }
548 return err;
549 }
550
551 IOException maxAttempts(String action, String key) {
552 return new IOException(MessageFormat.format(
553 JGitText.get().amazonS3ActionFailedGivingUp, action, key,
554 Integer.valueOf(maxAttempts)));
555 }
556
557 private HttpURLConnection open(final String method, final String bucket,
558 final String key) throws IOException {
559 final Map<String, String> noArgs = Collections.emptyMap();
560 return open(method, bucket, key, noArgs);
561 }
562
563 HttpURLConnection open(final String method, final String bucket,
564 final String key, final Map<String, String> args)
565 throws IOException {
566 final StringBuilder urlstr = new StringBuilder();
567 urlstr.append("http://"); //$NON-NLS-1$
568 urlstr.append(bucket);
569 urlstr.append('.');
570 urlstr.append(domain);
571 urlstr.append('/');
572 if (key.length() > 0)
573 HttpSupport.encode(urlstr, key);
574 if (!args.isEmpty()) {
575 final Iterator<Map.Entry<String, String>> i;
576
577 urlstr.append('?');
578 i = args.entrySet().iterator();
579 while (i.hasNext()) {
580 final Map.Entry<String, String> e = i.next();
581 urlstr.append(e.getKey());
582 urlstr.append('=');
583 HttpSupport.encode(urlstr, e.getValue());
584 if (i.hasNext())
585 urlstr.append('&');
586 }
587 }
588
589 final URL url = new URL(urlstr.toString());
590 final Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
591 final HttpURLConnection c;
592
593 c = (HttpURLConnection) url.openConnection(proxy);
594 c.setRequestMethod(method);
595 c.setRequestProperty("User-Agent", "jgit/1.0");
596 c.setRequestProperty("Date", httpNow());
597 return c;
598 }
599
600 void authorize(HttpURLConnection c) throws IOException {
601 final Map<String, List<String>> reqHdr = c.getRequestProperties();
602 final SortedMap<String, String> sigHdr = new TreeMap<>();
603 for (Map.Entry<String, List<String>> entry : reqHdr.entrySet()) {
604 final String hdr = entry.getKey();
605 if (isSignedHeader(hdr))
606 sigHdr.put(StringUtils.toLowerCase(hdr), toCleanString(entry.getValue()));
607 }
608
609 final StringBuilder s = new StringBuilder();
610 s.append(c.getRequestMethod());
611 s.append('\n');
612
613 s.append(remove(sigHdr, "content-md5"));
614 s.append('\n');
615
616 s.append(remove(sigHdr, "content-type"));
617 s.append('\n');
618
619 s.append(remove(sigHdr, "date"));
620 s.append('\n');
621
622 for (Map.Entry<String, String> e : sigHdr.entrySet()) {
623 s.append(e.getKey());
624 s.append(':');
625 s.append(e.getValue());
626 s.append('\n');
627 }
628
629 final String host = c.getURL().getHost();
630 s.append('/');
631 s.append(host.substring(0, host.length() - domain.length() - 1));
632 s.append(c.getURL().getPath());
633
634 final String sec;
635 try {
636 final Mac m = Mac.getInstance(HMAC);
637 m.init(privateKey);
638 sec = Base64.encodeBytes(m.doFinal(s.toString().getBytes(UTF_8)));
639 } catch (NoSuchAlgorithmException e) {
640 throw new IOException(MessageFormat.format(JGitText.get().noHMACsupport, HMAC, e.getMessage()));
641 } catch (InvalidKeyException e) {
642 throw new IOException(MessageFormat.format(JGitText.get().invalidKey, e.getMessage()));
643 }
644 c.setRequestProperty("Authorization", "AWS " + publicKey + ":" + sec);
645 }
646
647 static Properties properties(File authFile)
648 throws FileNotFoundException, IOException {
649 final Properties p = new Properties();
650 try (FileInputStream in = new FileInputStream(authFile)) {
651 p.load(in);
652 }
653 return p;
654 }
655
656 private final class ListParser extends DefaultHandler {
657 final List<String> entries = new ArrayList<>();
658
659 private final String bucket;
660
661 private final String prefix;
662
663 boolean truncated;
664
665 private StringBuilder data;
666
667 ListParser(String bn, String p) {
668 bucket = bn;
669 prefix = p;
670 }
671
672 void list() throws IOException {
673 final Map<String, String> args = new TreeMap<>();
674 if (prefix.length() > 0)
675 args.put("prefix", prefix);
676 if (!entries.isEmpty())
677 args.put("marker", prefix + entries.get(entries.size() - 1));
678
679 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
680 final HttpURLConnection c = open("GET", bucket, "", args);
681 authorize(c);
682 switch (HttpSupport.response(c)) {
683 case HttpURLConnection.HTTP_OK:
684 truncated = false;
685 data = null;
686
687 final XMLReader xr;
688 try {
689 xr = XMLReaderFactory.createXMLReader();
690 } catch (SAXException e) {
691 throw new IOException(JGitText.get().noXMLParserAvailable);
692 }
693 xr.setContentHandler(this);
694 try (InputStream in = c.getInputStream()) {
695 xr.parse(new InputSource(in));
696 } catch (SAXException parsingError) {
697 throw new IOException(
698 MessageFormat.format(
699 JGitText.get().errorListing, prefix),
700 parsingError);
701 }
702 return;
703
704 case HttpURLConnection.HTTP_INTERNAL_ERROR:
705 continue;
706
707 default:
708 throw AmazonS3.this.error("Listing", prefix, c);
709 }
710 }
711 throw maxAttempts("Listing", prefix);
712 }
713
714 @Override
715 public void startElement(final String uri, final String name,
716 final String qName, final Attributes attributes)
717 throws SAXException {
718 if ("Key".equals(name) || "IsTruncated".equals(name))
719 data = new StringBuilder();
720 }
721
722 @Override
723 public void ignorableWhitespace(final char[] ch, final int s,
724 final int n) throws SAXException {
725 if (data != null)
726 data.append(ch, s, n);
727 }
728
729 @Override
730 public void characters(char[] ch, int s, int n)
731 throws SAXException {
732 if (data != null)
733 data.append(ch, s, n);
734 }
735
736 @Override
737 public void endElement(final String uri, final String name,
738 final String qName) throws SAXException {
739 if ("Key".equals(name))
740 entries.add(data.toString().substring(prefix.length()));
741 else if ("IsTruncated".equals(name))
742 truncated = StringUtils.equalsIgnoreCase("true", data.toString());
743 data = null;
744 }
745 }
746 }