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