/* * The MIT License * * Copyright (c) 2004-2010, InfraDNA, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package hudson.plugins.mercurial; import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; import com.cloudbees.plugins.credentials.CredentialsNameProvider; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; import com.cloudbees.plugins.credentials.common.UsernamePasswordCredentials; import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.KeyPair; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import hudson.AbortException; import hudson.EnvVars; import hudson.FilePath; import hudson.Launcher; import hudson.Launcher.ProcStarter; import hudson.model.AbstractBuild; import hudson.model.Node; import hudson.model.TaskListener; import hudson.remoting.VirtualChannel; import hudson.util.ArgumentListBuilder; import hudson.util.Secret; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.io.StringReader; import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.UnsupportedCharsetException; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import jenkins.MasterToSlaveFileCallable; import jenkins.model.Jenkins; import org.ini4j.Ini; /** * Encapsulates the invocation of the Mercurial command. */ public class HgExe { private final ArgumentListBuilder base; private final ArgumentListBuilder baseNoDebug; /** * Environment variables to invoke hg with. */ private final EnvVars env; public final Launcher launcher; public final Node node; public final TaskListener listener; private final Capability capability; private final FilePath sshPrivateKey; @Deprecated public HgExe(MercurialSCM scm, Launcher launcher, AbstractBuild build, TaskListener listener) throws IOException, InterruptedException { this(MercurialSCM.findInstallation(scm.getInstallation()), scm.getCredentials(build.getProject(), build.getEnvironment(listener)), launcher, build.getBuiltOn(), listener, build.getEnvironment(listener)); } @Deprecated public HgExe(MercurialSCM scm, Launcher launcher, Node node, TaskListener listener, EnvVars env) throws IOException, InterruptedException { this(MercurialSCM.findInstallation(scm.getInstallation()), null, launcher, node, listener, env); } /** * Creates a new launcher. * You <strong>must</strong> call {@link #close} in a {@code finally} block. * @param inst a particular Mercurial installation to use (optional) * @param credentials username/password or SSH private key credentials (optional) * @param launcher a way to run commands * @param node the machine to run commands on * @param listener a place to print errors * @param env environment variables to pass to the command * @throws IOException for various reasons * @throws InterruptedException for various reasons */ public HgExe(@CheckForNull MercurialInstallation inst, @CheckForNull StandardUsernameCredentials credentials, Launcher launcher, Node node, TaskListener listener, EnvVars env) throws IOException, InterruptedException { base = findHgExe(inst, credentials, node, listener, true); // TODO might be more efficient to have a single call returning ArgumentListBuilder[2]? baseNoDebug = findHgExe(inst, credentials, node, listener, false); if (credentials instanceof SSHUserPrivateKey) { final SSHUserPrivateKey cc = (SSHUserPrivateKey) credentials; List<String> keys = cc.getPrivateKeys(); byte[] keyData; if (keys.isEmpty()) { throw new IOException("No private key available"); } else if (keys.size() > 1) { throw new IOException("Multiple private keys found."); } else { keyData = keys.get(0).getBytes("US-ASCII"); } final Secret passphrase = cc.getPassphrase(); if (passphrase != null && /* TODO JENKINS-21283 */ passphrase.getPlainText().length() > 0) { try { KeyPair kp = KeyPair.load(new JSch(), keyData, null); if (!kp.decrypt(passphrase.getPlainText())) { throw new IOException("Passphrase did not decrypt SSH private key"); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); kp.writePrivateKey(baos); keyData = baos.toByteArray(); } catch (JSchException x) { throw new IOException("Did not manage to decrypt SSH private key: " + x, x); } } FilePath slaveRoot = node.getRootPath(); if (slaveRoot == null) { throw new IOException(node.getDisplayName() + " is offline"); } sshPrivateKey = slaveRoot.createTempFile("jenkins-mercurial", ".sshkey"); sshPrivateKey.chmod(0600); // just in case slave goes offline during command; createTempFile fails to do it: sshPrivateKey.act(new DeleteOnExit()); OutputStream os = sshPrivateKey.write(); try { os.write(keyData); } finally { os.close(); } for (ArgumentListBuilder b : new ArgumentListBuilder[] {base, baseNoDebug}) { b.add("--config"); // TODO do we really want to pass -l username? Usually the username is ‘hg’ and encoded in the URL. But seems harmless at least on bitbucket. b.addMasked(String.format("ui.ssh=ssh -i %s -l %s", sshPrivateKey.getRemote(), cc.getUsername())); } } else { sshPrivateKey = null; } this.node = node; this.env = env; env.put("HGPLAIN", "true"); this.launcher = launcher; this.listener = listener; this.capability = Capability.get(this); } private static final class DeleteOnExit extends MasterToSlaveFileCallable<Void> { private static final long serialVersionUID = 1; @Override public Void invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { f.deleteOnExit(); return null; } } public void close() throws IOException, InterruptedException { // TODO implement AutoCloseable in Java 7+ if (sshPrivateKey != null) { sshPrivateKey.delete(); } } private static ArgumentListBuilder findHgExe(@CheckForNull MercurialInstallation inst, @CheckForNull StandardUsernameCredentials credentials, Node node, TaskListener listener, boolean allowDebug) throws IOException, InterruptedException { ArgumentListBuilder b = new ArgumentListBuilder(); if (inst == null) { final Jenkins jenkins = Jenkins.getInstance(); if (jenkins == null) { throw new IOException("Jenkins instance is not ready"); } b.add(jenkins.getDescriptorByType(MercurialSCM.DescriptorImpl.class).getHgExe()); } else { // TODO what about forEnvironment? final String toolHome = inst.forNode(node, listener).getHome(); if (toolHome == null) { throw new IOException("Cannot determine tool home for " + inst); } b.add(inst.executableWithSubstitution(toolHome)); if (allowDebug && inst.getDebug()) { b.add("--debug"); } String config = inst.getConfig(); if (config != null) { for (Map.Entry<String,? extends Map<String,String>> entry : new Ini(new StringReader(config)).entrySet()) { String sectionName = entry.getKey(); for (Map.Entry<String,String> entry2 : entry.getValue().entrySet()) { b.add("--config", sectionName + '.' + entry2.getKey() + '=' + entry2.getValue()); } } } } if (credentials instanceof UsernamePasswordCredentials) { UsernamePasswordCredentials upc = (UsernamePasswordCredentials) credentials; b.add("--config", "auth.jenkins.prefix=*", "--config"); b.addMasked("auth.jenkins.username=" + upc.getUsername()); b.add("--config"); b.addMasked("auth.jenkins.password=" + upc.getPassword().getPlainText()); b.add("--config", "auth.jenkins.schemes=http https"); } else if (credentials != null && !(credentials instanceof SSHUserPrivateKey)) { throw new IOException("Support for credentials currently limited to username/password and SSH private key: " + CredentialsNameProvider.name(credentials)); } return b; } /** * Prepares to start the Mercurial command. * @param args some arguments as created by {@link #seed} and then appended to * @return a process starter with the correct launcher, arguments, listener, and environment variables configured */ public ProcStarter launch(ArgumentListBuilder args) { // set the default stdout return launcher.launch().cmds(args).stdout(listener).envs(env); } /** * For use with {@link #launch} (or similar) when running commands not inside a build and which therefore might not be easily killed. */ public static int joinWithPossibleTimeout(ProcStarter proc, boolean useTimeout, final TaskListener listener) throws IOException, InterruptedException { return useTimeout ? proc.start().joinWithTimeout(60 * 60, TimeUnit.SECONDS, listener) : proc.join(); } /** * Starts creating an argument list. * Initially adds only the Mercurial executable itself, possibly with a debug flag. * @param allowDebug whether to add a debug flag if the configured installation requested it * @return a builder */ public ArgumentListBuilder seed(boolean allowDebug) { return (allowDebug ? base : baseNoDebug).clone(); } @Deprecated /** * @deprecated Unused, since we need more control over the argument list in order to support credentials. */ public ProcStarter pull() { return run("pull"); } @Deprecated /** * @deprecated Unused, since we need more control over the argument list in order to support credentials. */ public ProcStarter clone(String... args) { return launch(seed(true).add("clone").add(args)); } public ProcStarter bundleAll(String file) { return run("bundle","--all",file); } public ProcStarter bundle(Collection<String> bases, String file) { ArgumentListBuilder args = seed(true).add("bundle"); for (String head : bases) { args.add("--base", head); } args.add(file); return launch(args); } public ProcStarter init(FilePath path) { return run("init",path.getRemote()); } public ProcStarter unbundle(String bundleFile) { return run("unbundle",bundleFile); } public ProcStarter cleanAll() { return run("--config", "extensions.purge=", "clean", "--all"); } /** * Runs arbitrary command. */ public ProcStarter run(String... args) { return launch(seed(true).add(args)); } /** * @deprecated Use {@link #seed} and {@link #launch} instead. */ @Deprecated public ProcStarter run(ArgumentListBuilder args) { return launch(seed(true).add(args.toCommandArray())); } /** * Obtains the heads of the repository. */ public Set<String> heads(FilePath repo, boolean useTimeout) throws IOException, InterruptedException { if (capability.headsIn15 == null) { try { Set<String> output = heads(repo, useTimeout, true); capability.headsIn15 = true; return output; } catch (AbortException x) { Set<String> output = heads(repo, useTimeout, false); capability.headsIn15 = false; return output; } } else { return heads(repo, useTimeout, capability.headsIn15); } } private Set<String> heads(FilePath repo, boolean useTimeout, boolean usingHg15Syntax) throws IOException, InterruptedException { ArgumentListBuilder args = new ArgumentListBuilder("heads", "--template", "{node}\\n"); if(usingHg15Syntax) args.add("--topo", "--closed"); String output = popen(repo,listener,useTimeout,args); Set<String> heads = new LinkedHashSet<String>(Arrays.asList(output.split("\n"))); heads.remove(""); return heads; } /** * Gets the revision ID or node of the tip of the workspace. * A 40-character hexadecimal string * @param rev the revision to identify; defaults to {@code .}, i.e. working copy */ public @CheckForNull String tip(FilePath repository, @Nullable String rev) throws IOException, InterruptedException { String id = popen(repository, listener, false, new ArgumentListBuilder("log", "--rev", rev != null ? rev : ".", "--template", "{node}")); if (!NODEID_PATTERN.matcher(id).matches()) { listener.error("Expected to get an id but got '" + id + "' instead."); return null; // HUDSON-7723 } return id; } /** * Gets the revision number of the tip of the workspace. * @param rev the revision to identify; defaults to {@code .}, i.e. working copy */ public @CheckForNull String tipNumber(FilePath repository, @Nullable String rev) throws IOException, InterruptedException { String id = popen(repository, listener, false, new ArgumentListBuilder("log", "--rev", rev != null ? rev : ".", "--template", "{rev}")); if (!REVISION_NUMBER_PATTERN.matcher(id).matches()) { listener.error(Messages.HgExe_expected_to_get_a_revision_number_but_got_instead(id)); return null; } return id; } /** * Gets the branch name of given revision number or of the current workspace. * @param rev the revision to identify; defaults to current working copy */ public @CheckForNull String branch(FilePath repository, @CheckForNull String rev) throws IOException, InterruptedException { ArgumentListBuilder builder = new ArgumentListBuilder("id", "--branch"); if (rev != null) builder.add("--rev", rev); String branch = popen(repository, listener, false, builder).trim(); if (branch.isEmpty()) { listener.error(Messages.HgExe_expected_to_get_a_branch_name_but_got_nothing()); return null; } return branch; } /** * Gets the version of used Mercurial installation. */ public @CheckForNull String version() throws IOException, InterruptedException { String version = popen(null, listener, false, new ArgumentListBuilder("version")); if (version.isEmpty()) { listener.error(Messages.HgExe_expected_to_get_hg_version_name_but_got_nothing()); return null; } Matcher m = Pattern.compile("^Mercurial Distributed SCM \\(version ([0-9][^)]*)\\)").matcher(version); if (!m.lookingAt() || m.groupCount() < 1) { listener.error(Messages.HgExe_cannot_extract_hg_version()); return null; } return m.group(1); } /** * Gets the current value of a specified config item. */ public String config(FilePath repository, String name) throws IOException, InterruptedException { return popen(repository, listener, false, new ArgumentListBuilder("showconfig", name)).trim(); } /** * Runs the command and captures the output. */ @NonNull public String popen(FilePath repository, TaskListener listener, boolean useTimeout, ArgumentListBuilder args) throws IOException, InterruptedException { args = seed(false).add(args.toCommandArray()); ByteArrayOutputStream data = new ByteArrayOutputStream(); if (joinWithPossibleTimeout(launch(args).pwd(repository).stdout(data), useTimeout, listener) == 0) { try { //TODO: consider using another charset return data.toString(Charset.defaultCharset().name()); } catch (UnsupportedCharsetException ex) { // Should never happen throw new IOException("Cannot perform a conversion using the default charset", ex); } } else { listener.error("Failed to run " + args.toStringWithQuote()); listener.getLogger().write(data.toByteArray()); throw new AbortException(); } } /** * Capability of a particular hg invocation configuration (and location) on a specific node. Cached. */ private static final class Capability { /** * Whether this supports 1.5-style "heads --topo ..." syntax. */ volatile Boolean headsIn15; private static final Map<Node, Map<List<String>, Capability>> MAP = new WeakHashMap<Node,Map<List<String>,Capability>>(); synchronized static Capability get(HgExe hg) { Map<List<String>,Capability> m = MAP.get(hg.node); if (m == null) { m = new HashMap<List<String>,Capability>(); MAP.put(hg.node, m); } List<String> hgConfig = hg.seed(false).toList(); Capability cap = m.get(hgConfig); if (cap==null) m.put(hgConfig,cap = new Capability()); return cap; } } /** * Pattern that matches revision ID. */ private static final Pattern NODEID_PATTERN = Pattern.compile("[0-9a-f]{40}"); private static final Pattern REVISION_NUMBER_PATTERN = Pattern.compile("[0-9]+"); /** * Checks whether a normalized path URL matches what a config file requested. * @param pathURL a URL (using {@code file} protocol if local) * @param pathAsInConfig a repository path as in {@link #config} on {@code paths.*} * @return true if the paths are similar, false if they are different locations */ static boolean pathEquals(@NonNull String pathURL, @NonNull String pathAsInConfig) { if (pathAsInConfig.equals(pathURL)) { return true; } if ((pathAsInConfig + '/').equals(pathURL)) { return true; } if (pathAsInConfig.equals(pathURL + '/')) { return true; } if (pathURL.startsWith("file:/") && URI.create(pathURL).equals(new File(pathAsInConfig).toURI())) { return true; } if (pathAsInConfig.startsWith("file:/") && URI.create(pathAsInConfig).equals(new File(pathURL).toURI())) { return true; } return false; } }