summaryrefslogtreecommitdiff
path: root/core/src/main/java/org/elasticsearch/plugins/PluginManager.java
blob: 3abd37a1241fe776293f5c740eb8ca00d4328153 (plain)
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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
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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
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
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
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch licenses this file to you under
 * the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.elasticsearch.plugins;

import org.apache.lucene.util.IOUtils;
import org.elasticsearch.Build;
import org.elasticsearch.ElasticsearchCorruptionException;
import org.elasticsearch.ElasticsearchTimeoutException;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.Version;
import org.elasticsearch.bootstrap.JarHell;
import org.elasticsearch.common.Randomness;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.cli.Terminal;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.http.client.HttpDownloadHelper;
import org.elasticsearch.common.io.FileSystemUtils;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.env.Environment;
import org.elasticsearch.plugins.PluginsService.Bundle;

import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.GroupPrincipal;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.UserPrincipal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.StreamSupport;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import static java.util.Collections.unmodifiableSet;
import static org.elasticsearch.common.Strings.hasLength;
import static org.elasticsearch.common.cli.Terminal.Verbosity.VERBOSE;
import static org.elasticsearch.common.io.FileSystemUtils.moveFilesWithoutOverwriting;
import static org.elasticsearch.common.util.set.Sets.newHashSet;

/**
 *
 */
public class PluginManager {

    public static final String PROPERTY_SUPPORT_STAGING_URLS = "es.plugins.staging";

    public enum OutputMode {
        DEFAULT, SILENT, VERBOSE
    }

    private static final Set<String> BLACKLIST = unmodifiableSet(newHashSet(
            "elasticsearch",
            "elasticsearch.bat",
            "elasticsearch.in.sh",
            "plugin",
            "plugin.bat",
            "service.bat"));

    static final Set<String> MODULES = unmodifiableSet(newHashSet(
            "lang-expression",
            "lang-groovy"));

    static final Set<String> OFFICIAL_PLUGINS = unmodifiableSet(newHashSet(
            "analysis-icu",
            "analysis-kuromoji",
            "analysis-phonetic",
            "analysis-smartcn",
            "analysis-stempel",
            "delete-by-query",
            "discovery-azure",
            "discovery-ec2",
            "discovery-gce",
            "discovery-multicast",
            "lang-javascript",
            "lang-plan-a",
            "lang-python",
            "mapper-attachments",
            "mapper-murmur3",
            "mapper-size",
            "reindex",
            "repository-azure",
            "repository-hdfs",
            "repository-s3",
            "store-smb"));

    private final Environment environment;
    private URL url;
    private OutputMode outputMode;
    private TimeValue timeout;

    public PluginManager(Environment environment, URL url, OutputMode outputMode, TimeValue timeout) {
        this.environment = environment;
        this.url = url;
        this.outputMode = outputMode;
        this.timeout = timeout;
    }

    public void downloadAndExtract(String name, Terminal terminal, boolean batch) throws IOException {
        if (name == null && url == null) {
            throw new IllegalArgumentException("plugin name or url must be supplied with install.");
        }

        if (!Files.exists(environment.pluginsFile())) {
            terminal.println("Plugins directory [%s] does not exist. Creating...", environment.pluginsFile());
            Files.createDirectory(environment.pluginsFile());
        }

        if (!Environment.isWritable(environment.pluginsFile())) {
            throw new IOException("plugin directory " + environment.pluginsFile() + " is read only");
        }

        PluginHandle pluginHandle;
        if (name != null) {
            pluginHandle = PluginHandle.parse(name);
            checkForForbiddenName(pluginHandle.name);
        } else {
            // if we have no name but url, use temporary name that will be overwritten later
            pluginHandle = new PluginHandle("temp_name" + Randomness.get().nextInt(), null, null);
        }

        Path pluginFile = download(pluginHandle, terminal);
        extract(pluginHandle, terminal, pluginFile, batch);
    }

    private Path download(PluginHandle pluginHandle, Terminal terminal) throws IOException {
        Path pluginFile = pluginHandle.newDistroFile(environment);

        HttpDownloadHelper downloadHelper = new HttpDownloadHelper();
        boolean downloaded = false;
        boolean verified = false;
        HttpDownloadHelper.DownloadProgress progress;
        if (outputMode == OutputMode.SILENT) {
            progress = new HttpDownloadHelper.NullProgress();
        } else {
            progress = new HttpDownloadHelper.VerboseProgress(terminal.writer());
        }

        // first, try directly from the URL provided
        if (url != null) {
            URL pluginUrl = url;
            boolean isSecureProcotol = "https".equalsIgnoreCase(pluginUrl.getProtocol());
            boolean isAuthInfoSet = !Strings.isNullOrEmpty(pluginUrl.getUserInfo());
            if (isAuthInfoSet && !isSecureProcotol) {
                throw new IOException("Basic auth is only supported for HTTPS!");
            }

            terminal.println("Trying %s ...", pluginUrl.toExternalForm());
            try {
                downloadHelper.download(pluginUrl, pluginFile, progress, this.timeout);
                downloaded = true;
                terminal.println("Verifying %s checksums if available ...", pluginUrl.toExternalForm());
                Tuple<URL, Path> sha1Info = pluginHandle.newChecksumUrlAndFile(environment, pluginUrl, "sha1");
                verified = downloadHelper.downloadAndVerifyChecksum(sha1Info.v1(), pluginFile,
                        sha1Info.v2(), progress, this.timeout, HttpDownloadHelper.SHA1_CHECKSUM);
                Tuple<URL, Path> md5Info = pluginHandle.newChecksumUrlAndFile(environment, pluginUrl, "md5");
                verified = verified || downloadHelper.downloadAndVerifyChecksum(md5Info.v1(), pluginFile,
                        md5Info.v2(), progress, this.timeout, HttpDownloadHelper.MD5_CHECKSUM);
            } catch (ElasticsearchTimeoutException | ElasticsearchCorruptionException e) {
                throw e;
            } catch (Exception e) {
                // ignore
                terminal.println("Failed: %s", ExceptionsHelper.detailedMessage(e));
            }
        } else {
            if (PluginHandle.isOfficialPlugin(pluginHandle.name, pluginHandle.user, pluginHandle.version)) {
                checkForOfficialPlugins(pluginHandle.name);
            }
        }

        if (!downloaded && url == null) {
            // We try all possible locations
            for (URL url : pluginHandle.urls()) {
                terminal.println("Trying %s ...", url.toExternalForm());
                try {
                    downloadHelper.download(url, pluginFile, progress, this.timeout);
                    downloaded = true;
                    terminal.println("Verifying %s checksums if available ...", url.toExternalForm());
                    Tuple<URL, Path> sha1Info = pluginHandle.newChecksumUrlAndFile(environment, url, "sha1");
                    verified = downloadHelper.downloadAndVerifyChecksum(sha1Info.v1(), pluginFile,
                            sha1Info.v2(), progress, this.timeout, HttpDownloadHelper.SHA1_CHECKSUM);
                    Tuple<URL, Path> md5Info = pluginHandle.newChecksumUrlAndFile(environment, url, "md5");
                    verified = verified || downloadHelper.downloadAndVerifyChecksum(md5Info.v1(), pluginFile,
                            md5Info.v2(), progress, this.timeout, HttpDownloadHelper.MD5_CHECKSUM);
                    break;
                } catch (ElasticsearchTimeoutException | ElasticsearchCorruptionException e) {
                    throw e;
                } catch (Exception e) {
                    terminal.println(VERBOSE, "Failed: %s", ExceptionsHelper.detailedMessage(e));
                }
            }
        }

        if (!downloaded) {
            // try to cleanup what we downloaded
            IOUtils.deleteFilesIgnoringExceptions(pluginFile);
            throw new IOException("failed to download out of all possible locations..., use --verbose to get detailed information");
        }

        if (verified == false) {
            terminal.println("NOTE: Unable to verify checksum for downloaded plugin (unable to find .sha1 or .md5 file to verify)");
        }
        return pluginFile;
    }

    private void extract(PluginHandle pluginHandle, Terminal terminal, Path pluginFile, boolean batch) throws IOException {
        // unzip plugin to a staging temp dir, named for the plugin
        Path tmp = Files.createTempDirectory(environment.tmpFile(), null);
        Path root = tmp.resolve(pluginHandle.name);
        unzipPlugin(pluginFile, root);

        // find the actual root (in case its unzipped with extra directory wrapping)
        root = findPluginRoot(root);

        // read and validate the plugin descriptor
        PluginInfo info = PluginInfo.readFromProperties(root);
        terminal.println(VERBOSE, "%s", info);

        // don't let luser install plugin as a module...
        // they might be unavoidably in maven central and are packaged up the same way)
        if (MODULES.contains(info.getName())) {
            throw new IOException("plugin '" + info.getName() + "' cannot be installed like this, it is a system module");
        }

        // update name in handle based on 'name' property found in descriptor file
        pluginHandle = new PluginHandle(info.getName(), pluginHandle.version, pluginHandle.user);
        final Path extractLocation = pluginHandle.extractedDir(environment);
        if (Files.exists(extractLocation)) {
            throw new IOException("plugin directory " + extractLocation.toAbsolutePath() + " already exists. To update the plugin, uninstall it first using 'remove " + pluginHandle.name + "' command");
        }

        // check for jar hell before any copying
        if (info.isJvm()) {
            jarHellCheck(root, info.isIsolated());
        }

        // read optional security policy (extra permissions)
        // if it exists, confirm or warn the user
        Path policy = root.resolve(PluginInfo.ES_PLUGIN_POLICY);
        if (Files.exists(policy)) {
            PluginSecurity.readPolicy(policy, terminal, environment, batch);
        }

        // install plugin
        FileSystemUtils.copyDirectoryRecursively(root, extractLocation);
        terminal.println("Installed %s into %s", pluginHandle.name, extractLocation.toAbsolutePath());

        // cleanup
        tryToDeletePath(terminal, tmp, pluginFile);

        // take care of bin/ by moving and applying permissions if needed
        Path sourcePluginBinDirectory = extractLocation.resolve("bin");
        Path destPluginBinDirectory = pluginHandle.binDir(environment);
        boolean needToCopyBinDirectory = Files.exists(sourcePluginBinDirectory);
        if (needToCopyBinDirectory) {
            if (Files.exists(destPluginBinDirectory) && !Files.isDirectory(destPluginBinDirectory)) {
                tryToDeletePath(terminal, extractLocation);
                throw new IOException("plugin bin directory " + destPluginBinDirectory + " is not a directory");
            }

            try {
                copyBinDirectory(sourcePluginBinDirectory, destPluginBinDirectory, pluginHandle.name, terminal);
            } catch (IOException e) {
                // rollback and remove potentially before installed leftovers
                terminal.printError("Error copying bin directory [%s] to [%s], cleaning up, reason: %s", sourcePluginBinDirectory, destPluginBinDirectory, ExceptionsHelper.detailedMessage(e));
                tryToDeletePath(terminal, extractLocation, pluginHandle.binDir(environment));
                throw e;
            }

        }

        Path sourceConfigDirectory = extractLocation.resolve("config");
        Path destConfigDirectory = pluginHandle.configDir(environment);
        boolean needToCopyConfigDirectory = Files.exists(sourceConfigDirectory);
        if (needToCopyConfigDirectory) {
            if (Files.exists(destConfigDirectory) && !Files.isDirectory(destConfigDirectory)) {
                tryToDeletePath(terminal, extractLocation, destPluginBinDirectory);
                throw new IOException("plugin config directory " + destConfigDirectory + " is not a directory");
            }

            try {
                terminal.println(VERBOSE, "Found config, moving to %s", destConfigDirectory.toAbsolutePath());
                moveFilesWithoutOverwriting(sourceConfigDirectory, destConfigDirectory, ".new");

                if (Environment.getFileStore(destConfigDirectory).supportsFileAttributeView(PosixFileAttributeView.class)) {
                    //We copy owner, group and permissions from the parent ES_CONFIG directory, assuming they were properly set depending
                    // on how es was installed in the first place: can be root:elasticsearch (750) if es was installed from rpm/deb packages
                    // or most likely elasticsearch:elasticsearch if installed from tar/zip. As for permissions we don't rely on umask.
                    PosixFileAttributes parentDirAttributes = Files.getFileAttributeView(destConfigDirectory.getParent(), PosixFileAttributeView.class).readAttributes();
                    //for files though, we make sure not to copy execute permissions from the parent dir and leave them untouched
                    Set<PosixFilePermission> baseFilePermissions = new HashSet<>();
                    for (PosixFilePermission posixFilePermission : parentDirAttributes.permissions()) {
                        switch (posixFilePermission) {
                            case OWNER_EXECUTE:
                            case GROUP_EXECUTE:
                            case OTHERS_EXECUTE:
                                break;
                            default:
                                baseFilePermissions.add(posixFilePermission);
                        }
                    }
                    Files.walkFileTree(destConfigDirectory, new SimpleFileVisitor<Path>() {
                        @Override
                        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                            if (attrs.isRegularFile()) {
                                Set<PosixFilePermission> newFilePermissions = new HashSet<>(baseFilePermissions);
                                Set<PosixFilePermission> currentFilePermissions = Files.getPosixFilePermissions(file);
                                for (PosixFilePermission posixFilePermission : currentFilePermissions) {
                                    switch (posixFilePermission) {
                                        case OWNER_EXECUTE:
                                        case GROUP_EXECUTE:
                                        case OTHERS_EXECUTE:
                                            newFilePermissions.add(posixFilePermission);
                                    }
                                }
                                setPosixFileAttributes(file, parentDirAttributes.owner(), parentDirAttributes.group(), newFilePermissions);
                            }
                            return FileVisitResult.CONTINUE;
                        }

                        @Override
                        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                            setPosixFileAttributes(dir, parentDirAttributes.owner(), parentDirAttributes.group(), parentDirAttributes.permissions());
                            return FileVisitResult.CONTINUE;
                        }
                    });
                } else {
                    terminal.println(VERBOSE, "Skipping posix permissions - filestore doesn't support posix permission");
                }

                terminal.println(VERBOSE, "Installed %s into %s", pluginHandle.name, destConfigDirectory.toAbsolutePath());
            } catch (IOException e) {
                terminal.printError("Error copying config directory [%s] to [%s], cleaning up, reason: %s", sourceConfigDirectory, destConfigDirectory, ExceptionsHelper.detailedMessage(e));
                tryToDeletePath(terminal, extractLocation, destPluginBinDirectory, destConfigDirectory);
                throw e;
            }
        }
    }

    private static void setPosixFileAttributes(Path path, UserPrincipal owner, GroupPrincipal group, Set<PosixFilePermission> permissions) throws IOException {
        PosixFileAttributeView fileAttributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class);
        fileAttributeView.setOwner(owner);
        fileAttributeView.setGroup(group);
        fileAttributeView.setPermissions(permissions);
    }

    static void tryToDeletePath(Terminal terminal, Path ... paths) {
        for (Path path : paths) {
            try {
                IOUtils.rm(path);
            } catch (IOException e) {
                terminal.printError(e);
            }
        }
    }

    private void copyBinDirectory(Path sourcePluginBinDirectory, Path destPluginBinDirectory, String pluginName, Terminal terminal) throws IOException {
        boolean canCopyFromSource = Files.exists(sourcePluginBinDirectory) && Files.isReadable(sourcePluginBinDirectory) && Files.isDirectory(sourcePluginBinDirectory);
        if (canCopyFromSource) {
            terminal.println(VERBOSE, "Found bin, moving to %s", destPluginBinDirectory.toAbsolutePath());
            if (Files.exists(destPluginBinDirectory)) {
                IOUtils.rm(destPluginBinDirectory);
            }
            try {
                Files.createDirectories(destPluginBinDirectory.getParent());
                FileSystemUtils.move(sourcePluginBinDirectory, destPluginBinDirectory);
            } catch (IOException e) {
                throw new IOException("Could not move [" + sourcePluginBinDirectory + "] to [" + destPluginBinDirectory + "]", e);
            }
            if (Environment.getFileStore(destPluginBinDirectory).supportsFileAttributeView(PosixFileAttributeView.class)) {
                PosixFileAttributes parentDirAttributes = Files.getFileAttributeView(destPluginBinDirectory.getParent(), PosixFileAttributeView.class).readAttributes();
                //copy permissions from parent bin directory
                Set<PosixFilePermission> filePermissions = new HashSet<>();
                for (PosixFilePermission posixFilePermission : parentDirAttributes.permissions()) {
                    switch (posixFilePermission) {
                        case OWNER_EXECUTE:
                        case GROUP_EXECUTE:
                        case OTHERS_EXECUTE:
                            break;
                        default:
                            filePermissions.add(posixFilePermission);
                    }
                }
                // add file execute permissions to existing perms, so execution will work.
                filePermissions.add(PosixFilePermission.OWNER_EXECUTE);
                filePermissions.add(PosixFilePermission.GROUP_EXECUTE);
                filePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
                Files.walkFileTree(destPluginBinDirectory, new SimpleFileVisitor<Path>() {
                    @Override
                    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                        if (attrs.isRegularFile()) {
                            setPosixFileAttributes(file, parentDirAttributes.owner(), parentDirAttributes.group(), filePermissions);
                        }
                        return FileVisitResult.CONTINUE;
                    }

                    @Override
                    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                        setPosixFileAttributes(dir, parentDirAttributes.owner(), parentDirAttributes.group(), parentDirAttributes.permissions());
                        return FileVisitResult.CONTINUE;
                    }
                });
            } else {
                terminal.println(VERBOSE, "Skipping posix permissions - filestore doesn't support posix permission");
            }
            terminal.println(VERBOSE, "Installed %s into %s", pluginName, destPluginBinDirectory.toAbsolutePath());
        }
    }

    /** we check whether we need to remove the top-level folder while extracting
     *  sometimes (e.g. github) the downloaded archive contains a top-level folder which needs to be removed
     */
    private Path findPluginRoot(Path dir) throws IOException {
        if (Files.exists(dir.resolve(PluginInfo.ES_PLUGIN_PROPERTIES))) {
            return dir;
        } else {
            final Path[] topLevelFiles = FileSystemUtils.files(dir);
            if (topLevelFiles.length == 1 && Files.isDirectory(topLevelFiles[0])) {
                Path subdir = topLevelFiles[0];
                if (Files.exists(subdir.resolve(PluginInfo.ES_PLUGIN_PROPERTIES))) {
                    return subdir;
                }
            }
        }
        throw new RuntimeException("Could not find plugin descriptor '" + PluginInfo.ES_PLUGIN_PROPERTIES + "' in plugin zip");
    }

    /** check a candidate plugin for jar hell before installing it */
    private void jarHellCheck(Path candidate, boolean isolated) throws IOException {
        // create list of current jars in classpath
        final List<URL> jars = new ArrayList<>();
        jars.addAll(Arrays.asList(JarHell.parseClassPath()));

        // read existing bundles. this does some checks on the installation too.
        List<Bundle> bundles = PluginsService.getPluginBundles(environment.pluginsFile());

        // if we aren't isolated, we need to jarhellcheck against any other non-isolated plugins
        // thats always the first bundle
        if (isolated == false) {
            jars.addAll(bundles.get(0).urls);
        }

        // add plugin jars to the list
        Path pluginJars[] = FileSystemUtils.files(candidate, "*.jar");
        for (Path jar : pluginJars) {
            jars.add(jar.toUri().toURL());
        }

        // check combined (current classpath + new jars to-be-added)
        try {
            JarHell.checkJarHell(jars.toArray(new URL[jars.size()]));
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }

    private void unzipPlugin(Path zip, Path target) throws IOException {
        Files.createDirectories(target);

        try (ZipInputStream zipInput = new ZipInputStream(Files.newInputStream(zip))) {
            ZipEntry entry;
            byte[] buffer = new byte[8192];
            while ((entry = zipInput.getNextEntry()) != null) {
                Path targetFile = target.resolve(entry.getName());

                // be on the safe side: do not rely on that directories are always extracted
                // before their children (although this makes sense, but is it guaranteed?)
                Files.createDirectories(targetFile.getParent());
                if (entry.isDirectory() == false) {
                    try (OutputStream out = Files.newOutputStream(targetFile)) {
                        int len;
                        while((len = zipInput.read(buffer)) >= 0) {
                            out.write(buffer, 0, len);
                        }
                    }
                }
                zipInput.closeEntry();
            }
        }
    }

    public void removePlugin(String name, Terminal terminal) throws IOException {
        if (name == null) {
            throw new IllegalArgumentException("plugin name must be supplied with remove [name].");
        }
        PluginHandle pluginHandle = PluginHandle.parse(name);
        boolean removed = false;

        checkForForbiddenName(pluginHandle.name);
        Path pluginToDelete = pluginHandle.extractedDir(environment);
        if (Files.exists(pluginToDelete)) {
            terminal.println(VERBOSE, "Removing: %s", pluginToDelete);
            try {
                IOUtils.rm(pluginToDelete);
            } catch (IOException ex){
                throw new IOException("Unable to remove " + pluginHandle.name + ". Check file permissions on " +
                        pluginToDelete.toString(), ex);
            }
            removed = true;
        }
        Path binLocation = pluginHandle.binDir(environment);
        if (Files.exists(binLocation)) {
            terminal.println(VERBOSE, "Removing: %s", binLocation);
            try {
                IOUtils.rm(binLocation);
            } catch (IOException ex){
                throw new IOException("Unable to remove " + pluginHandle.name + ". Check file permissions on " +
                        binLocation.toString(), ex);
            }
            removed = true;
        }

        if (removed) {
            terminal.println("Removed %s", name);
        } else {
            terminal.println("Plugin %s not found. Run \"plugin list\" to get list of installed plugins.", name);
        }
    }

    static void checkForForbiddenName(String name) {
        if (!hasLength(name) || BLACKLIST.contains(name.toLowerCase(Locale.ROOT))) {
            throw new IllegalArgumentException("Illegal plugin name: " + name);
        }
    }

    protected static void checkForOfficialPlugins(String name) {
        // We make sure that users can use only new short naming for official plugins only
        if (!OFFICIAL_PLUGINS.contains(name)) {
            throw new IllegalArgumentException(name +
                    " is not an official plugin so you should install it using elasticsearch/" +
                    name + "/latest naming form.");
        }
    }

    public Path[] getListInstalledPlugins() throws IOException {
        if (!Files.exists(environment.pluginsFile())) {
            return new Path[0];
        }

        try (DirectoryStream<Path> stream = Files.newDirectoryStream(environment.pluginsFile())) {
            return StreamSupport.stream(stream.spliterator(), false).toArray(length -> new Path[length]);
        }
    }

    public void listInstalledPlugins(Terminal terminal) throws IOException {
        Path[] plugins = getListInstalledPlugins();
        terminal.println("Installed plugins in %s:", environment.pluginsFile().toAbsolutePath());
        if (plugins == null || plugins.length == 0) {
            terminal.println("    - No plugin detected");
        } else {
            for (Path plugin : plugins) {
                terminal.println("    - " + plugin.getFileName());
            }
        }
    }

    /**
     * Helper class to extract properly user name, repository name, version and plugin name
     * from plugin name given by a user.
     */
    static class PluginHandle {

        final String version;
        final String user;
        final String name;

        PluginHandle(String name, String version, String user) {
            this.version = version;
            this.user = user;
            this.name = name;
        }

        List<URL> urls() {
            List<URL> urls = new ArrayList<>();
            if (version != null) {
                // Elasticsearch new download service uses groupId org.elasticsearch.plugin from 2.0.0
                if (user == null) {
                    if (!Strings.isNullOrEmpty(System.getProperty(PROPERTY_SUPPORT_STAGING_URLS))) {
                        addUrl(urls, String.format(Locale.ROOT, "https://download.elastic.co/elasticsearch/staging/%s-%s/org/elasticsearch/plugin/%s/%s/%s-%s.zip", version, Build.CURRENT.shortHash(), name, version, name, version));
                    }
                    addUrl(urls, String.format(Locale.ROOT, "https://download.elastic.co/elasticsearch/release/org/elasticsearch/plugin/%s/%s/%s-%s.zip", name, version, name, version));
                } else {
                    // Elasticsearch old download service
                    addUrl(urls, String.format(Locale.ROOT, "https://download.elastic.co/%1$s/%2$s/%2$s-%3$s.zip", user, name, version));
                    // Maven central repository
                    addUrl(urls, String.format(Locale.ROOT, "https://search.maven.org/remotecontent?filepath=%1$s/%2$s/%3$s/%2$s-%3$s.zip", user.replace('.', '/'), name, version));
                    // Sonatype repository
                    addUrl(urls, String.format(Locale.ROOT, "https://oss.sonatype.org/service/local/repositories/releases/content/%1$s/%2$s/%3$s/%2$s-%3$s.zip", user.replace('.', '/'), name, version));
                    // Github repository
                    addUrl(urls, String.format(Locale.ROOT, "https://github.com/%1$s/%2$s/archive/%3$s.zip", user, name, version));
                }
            }
            if (user != null) {
                // Github repository for master branch (assume site)
                addUrl(urls, String.format(Locale.ROOT, "https://github.com/%1$s/%2$s/archive/master.zip", user, name));
            }
            return urls;
        }

        private static void addUrl(List<URL> urls, String url) {
            try {
                urls.add(new URL(url));
            } catch (MalformedURLException e) {
                // We simply ignore malformed URL
            }
        }

        Path newDistroFile(Environment env) throws IOException {
            return Files.createTempFile(env.tmpFile(), name, ".zip");
        }

        Tuple<URL, Path> newChecksumUrlAndFile(Environment env, URL originalUrl, String suffix) throws IOException {
            URL newUrl = new URL(originalUrl.toString() + "." + suffix);
            return new Tuple<>(newUrl, Files.createTempFile(env.tmpFile(), name, ".zip." + suffix));
        }

        Path extractedDir(Environment env) {
            return env.pluginsFile().resolve(name);
        }

        Path binDir(Environment env) {
            return env.binFile().resolve(name);
        }

        Path configDir(Environment env) {
            return env.configFile().resolve(name);
        }

        static PluginHandle parse(String name) {
            String[] elements = name.split("/");
            // We first consider the simplest form: pluginname
            String repo = elements[0];
            String user = null;
            String version = null;

            // We consider the form: username/pluginname
            if (elements.length > 1) {
                user = elements[0];
                repo = elements[1];

                // We consider the form: username/pluginname/version
                if (elements.length > 2) {
                    version = elements[2];
                }
            }

            if (isOfficialPlugin(repo, user, version)) {
                return new PluginHandle(repo, Version.CURRENT.number(), null);
            }

            return new PluginHandle(repo, version, user);
        }

        static boolean isOfficialPlugin(String repo, String user, String version) {
            return version == null && user == null && !Strings.isNullOrEmpty(repo);
        }
    }

}