/* * The MIT License * * Copyright (c) 2016 CloudBees, 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 jenkins.scm.impl.mock; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.FilePath; import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.math.BigInteger; import java.nio.charset.Charset; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.UUID; import java.util.WeakHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import jenkins.scm.api.SCMFile; import org.apache.commons.io.FileUtils; import org.apache.commons.lang.StringUtils; import org.jvnet.hudson.test.recipes.LocalData; public class MockSCMController implements Closeable { private static Map<String, MockSCMController> instances = new WeakHashMap<String, MockSCMController>(); private final String id; private Map<String, Repository> repositories = new TreeMap<String, Repository>(); private List<MockFailure> faults = new ArrayList<MockFailure>(); @NonNull private MockLatency latency = MockLatency.none(); private String displayName; private String description; private String url; private String repoIconClassName; private String orgIconClassName; private MockSCMController() { this(UUID.randomUUID().toString()); } public MockSCMController(String id) { this.id = id; } public static MockSCMController create() { MockSCMController c = new MockSCMController(); synchronized (instances) { instances.put(c.id, c); } return c; } public MockSCMController withLatency(@NonNull MockLatency latency) { this.latency = latency; return this; } /** * (Re)creates a {@link MockSCMController} for use when you are running a data migration test. * It will be the callers responsibility to restore the state of the {@link MockSCMController} accordingly. * Only use this if you are using {@link LocalData} which contains references to specific {@link MockSCMController} * ids and you need to re-wire up the link. * * @param id the ID of the controller used when seeding the original data. * @return the {@link MockSCMController}. * @deprecated (not actually deprecated) a warning that you should only be using this from a test annotated with * {@link LocalData} */ @Deprecated // only intended for use from data migration tests where you have a pre-provisioned home public static MockSCMController recreate(String id) { MockSCMController c = new MockSCMController(id); synchronized (instances) { if (instances.containsKey(id)) { return instances.get(id); } instances.put(id, c); } return c; } public static List<MockSCMController> all() { synchronized (instances) { return new ArrayList<MockSCMController>(instances.values()); } } public static MockSCMController lookup(String id) { synchronized (instances) { return instances.get(id); } } public String getId() { return id; } @Override public void close() { synchronized (instances) { instances.remove(id); } repositories.clear(); } public String getRepoIconClassName() { return repoIconClassName; } public void setRepoIconClassName(String repoIconClassName) { this.repoIconClassName = repoIconClassName; } public String getOrgIconClassName() { return orgIconClassName; } public void setOrgIconClassName(String orgIconClassName) { this.orgIconClassName = orgIconClassName; } public String getDescription() throws IOException { return description; } public void setDescription(String description) throws IOException { this.description = description; } public String getDisplayName() throws IOException { return displayName; } public void setDisplayName(String displayName) throws IOException { this.displayName = displayName; } public String getUrl() throws IOException { return url; } public void setUrl(String url) throws IOException { this.url = url; } public synchronized void addFault(MockFailure fault) { this.faults.add(fault); } public synchronized void clearFaults() { this.faults.clear(); } public void applyLatency() throws InterruptedException { MockLatency latency; synchronized (this) { latency = this.latency; } latency.apply(); } public void checkFaults(@CheckForNull String repository, @CheckForNull String branch, @CheckForNull String revision, boolean actions) throws IOException, InterruptedException { List<MockFailure> faults; synchronized (this) { faults = new ArrayList<MockFailure>(this.faults); } for (MockFailure fault: faults) { fault.check(repository, branch, revision, actions); } } public synchronized void createRepository(String name, MockRepositoryFlags... flags) throws IOException { repositories.put(name, new Repository(flags)); createBranch(name, "master"); } public synchronized void deleteRepository(String name) throws IOException { repositories.remove(name); } public synchronized List<String> listRepositories() throws IOException { return new ArrayList<String>(repositories.keySet()); } public String getDescription(String repository) throws IOException { return resolve(repository).description; } public Set<MockRepositoryFlags> getFlags(String repository) throws IOException { return Collections.unmodifiableSet(resolve(repository).flags); } public void setDescription(String repository, String description) throws IOException { resolve(repository).description = description; } public String getDisplayName(String repository) throws IOException { return resolve(repository).displayName; } public void setDisplayName(String repository, String displayName) throws IOException { resolve(repository).displayName = displayName; } public String getUrl(String repository) throws IOException { return resolve(repository).url; } public void setUrl(String repository, String url) throws IOException { resolve(repository).url = url; } public synchronized void createBranch(String repository, String branch) throws IOException { State state = new State(); Repository repo = resolve(repository); repo.revisions.put(state.getHash(), state); repo.heads.put(branch, state.getHash()); } public synchronized void cloneBranch(String repository, String srcBranch, String dstBranch) throws IOException { Repository repo = resolve(repository); repo.heads.put(dstBranch, repo.heads.get(srcBranch)); } public synchronized void deleteBranch(String repository, String branch) throws IOException { resolve(repository).heads.remove(branch); } public synchronized long createTag(String repository, String branch, String tag) throws IOException { Repository repo = resolve(repository); long timestamp = System.currentTimeMillis(); repo.tags.put(tag, resolve(repository, branch).getHash()); repo.tagDates.put(tag, timestamp); return timestamp; } public synchronized void createTag(String repository, String branch, String tag, long when) throws IOException { Repository repo = resolve(repository); repo.tags.put(tag, resolve(repository, branch).getHash()); repo.tagDates.put(tag, when); } public synchronized void deleteTag(String repository, String tag) throws IOException { resolve(repository).tags.remove(tag); resolve(repository).tagDates.remove(tag); } public synchronized Integer openChangeRequest(String repository, String branch, MockChangeRequestFlags... flags) throws IOException { Repository repo = resolve(repository); String hash = resolve(repository, branch).getHash(); Integer crNum = ++repo.lastChangeRequest; repo.changes.put(crNum, hash); repo.changeBaselines.put(crNum, branch); Set<MockChangeRequestFlags> flagsSet = EnumSet.noneOf(MockChangeRequestFlags.class); for (MockChangeRequestFlags flag: flags) { if (flag.isApplicable(repo.flags)) { flagsSet.add(flag); } } repo.changeFlags.put(crNum, flagsSet); return crNum; } public synchronized void closeChangeRequest(String repository, Integer crNum) throws IOException { Repository r = resolve(repository); r.changes.remove(crNum); r.changeBaselines.remove(crNum); r.changeFlags.remove(crNum); } public synchronized String getTarget(String repository, Integer crNum) throws IOException { return resolve(repository).changeBaselines.get(crNum); } public synchronized Set<MockChangeRequestFlags> getFlags(String repository, Integer crNum) throws IOException { return Collections.unmodifiableSet(resolve(repository).changeFlags.get(crNum)); } public synchronized List<String> listBranches(String repository) throws IOException { return new ArrayList<String>(resolve(repository).heads.keySet()); } public synchronized List<String> listTags(String repository) throws IOException { return new ArrayList<String>(resolve(repository).tags.keySet()); } public synchronized List<Integer> listChangeRequests(String repository) throws IOException { return new ArrayList<Integer>(resolve(repository).changes.keySet()); } public synchronized String getRevision(String repository, String branch) throws IOException { return resolve(repository, branch).getHash(); } public synchronized void addFile(String repository, String branchOrCR, String message, String path, byte[] content) throws IOException { Repository repo = resolve(repository); String branchName; Integer crNum; String hash; // check branch first hash = repo.heads.get(branchOrCR); if (hash == null) { branchName = null; Matcher m = Pattern.compile("change-request/(\\d+)").matcher(branchOrCR); if (m.matches()) { crNum = Integer.valueOf(m.group(1)); hash = repo.changes.get(crNum); if (hash == null) { throw new IOException("Unknown change request: " + crNum + " in repository " + repository); } } else { throw new IOException("Unknown branch: " + branchOrCR + " in repository " + repository); } } else { branchName = branchOrCR; crNum = null; } State base = repo.revisions.get(hash); State state = new State(base, message, Collections.singletonMap(path, content), Collections.<String>emptySet()); repo.revisions.put(state.getHash(), state); if (branchName != null) { repo.heads.put(branchName, state.getHash()); } if (crNum != null) { repo.changes.put(crNum, state.getHash()); } } public synchronized void rmFile(String repository, String branchOrCR, String message, String path) throws IOException { Repository repo = resolve(repository); String branchName; Integer crNum; String hash; // check branch first hash = repo.heads.get(branchOrCR); if (hash == null) { branchName = null; Matcher m = Pattern.compile("change-request/(\\d+)").matcher(branchOrCR); if (m.matches()) { crNum = Integer.valueOf(m.group(1)); hash = repo.changes.get(crNum); if (hash == null) { throw new IOException("Unknown change request: " + crNum + " in repository " + repository); } } else { throw new IOException("Unknown branch: " + branchOrCR + " in repository " + repository); } } else { branchName = branchOrCR; crNum = null; } State base = repo.revisions.get(hash); State state = new State(base, message, Collections.<String, byte[]>emptyMap(), Collections.<String>singleton(path)); repo.revisions.put(state.getHash(), state); if (branchName != null) { repo.heads.put(branchName, state.getHash()); } if (crNum != null) { repo.changes.put(crNum, state.getHash()); } } public synchronized String checkout(File workspace, String repository, String identifier) throws IOException { State state = resolve(repository, identifier); for (Map.Entry<String, byte[]> entry : state.files.entrySet()) { FileUtils.writeByteArrayToFile(new File(workspace, entry.getKey()), entry.getValue()); } return state.getHash(); } public synchronized String checkout(FilePath workspace, String repository, String identifier) throws IOException, InterruptedException { State state = resolve(repository, identifier); for (Map.Entry<String, byte[]> entry : state.files.entrySet()) { workspace.child(entry.getKey()).copyFrom(new ByteArrayInputStream(entry.getValue())); } return state.getHash(); } private synchronized State resolve(String repository, String identifier) throws IOException { Repository repo = resolve(repository); // check hash first String hash = repo.revisions.containsKey(identifier) ? identifier : null; if (hash != null) { return repo.revisions.get(hash); } // now check for a named branch hash = repo.heads.get(identifier); if (hash != null) { return repo.revisions.get(hash); } // now check for a named tag hash = repo.tags.get(identifier); if (hash != null) { return repo.revisions.get(hash); } // now check for a change request Matcher m = Pattern.compile("change-request/(\\d+)").matcher(identifier); if (m.matches()) { Integer crNum = Integer.valueOf(m.group(1)); hash = repo.changes.get(crNum); if (hash != null) { return repo.revisions.get(hash); } throw new IOException("Unknown change request: " + crNum + " in repository " + repository); } throw new IOException("Unknown branch/tag/revision: " + identifier + " in repository " + repository); } private Repository resolve(String repository) throws IOException { Repository repo = repositories.get(repository); if (repo == null) { throw new IOException("Unknown repository: " + repository); } return repo; } public synchronized List<LogEntry> log(String repository, String identifier) throws IOException { State state = resolve(repository, identifier); List<LogEntry> result = new ArrayList<LogEntry>(); while (state != null) { result.add(new LogEntry(state.getHash(), state.timestamp, state.message)); state = state.parent; } return result; } public synchronized SCMFile.Type stat(String repository, String identifier, String path) throws IOException { State state = resolve(repository, identifier); if (state == null) { return SCMFile.Type.NONEXISTENT; } if (state.files.containsKey(path)) { return SCMFile.Type.REGULAR_FILE; } for (String p : state.files.keySet()) { if (p.startsWith(path + "/")) { return SCMFile.Type.DIRECTORY; } } return SCMFile.Type.NONEXISTENT; } public synchronized long lastModified(String repository, String identifier) { try { State state = resolve(repository, identifier); if (state == null) { return 0L; } return state.timestamp; } catch (IOException e) { return 0L; } } public synchronized long getTagTimestamp(String repository, String tag) throws IOException { Repository repo = repositories.get(repository); if (repo == null) { throw new IOException("Unknown repository: " + repository); } Long date = repo.tagDates.get(tag); if (tag == null) { throw new IOException("Unknown tag: " + tag + " in repository " + repository); } return date; } private static class Repository { private Map<String, State> revisions = new TreeMap<String, State>(); private Map<String, String> heads = new TreeMap<String, String>(); private Map<String, String> tags = new TreeMap<String, String>(); private Map<String, Long> tagDates = new TreeMap<String, Long>(); private Map<Integer, String> changes = new TreeMap<Integer, String>(); private Map<Integer, Set<MockChangeRequestFlags>> changeFlags = new TreeMap<Integer, Set<MockChangeRequestFlags>>(); private Map<Integer, String> changeBaselines = new TreeMap<Integer, String>(); private int lastChangeRequest; private String description; private String displayName; private String url; private Set<MockRepositoryFlags> flags; private Repository(MockRepositoryFlags... flags) { this.flags = flags.length == 0 ? Collections.<MockRepositoryFlags>emptySet() : EnumSet.copyOf(Arrays.asList(flags)); } } private static class State { private final State parent; private final String message; private final long timestamp; private final Map<String, byte[]> files; private transient String hash; public State() { this.parent = null; this.message = null; this.timestamp = System.currentTimeMillis(); this.files = new TreeMap<String, byte[]>(); } public State(State parent, String message, Map<String, byte[]> added, Set<String> removed) { this.parent = parent; this.message = message; this.timestamp = System.currentTimeMillis(); Map<String, byte[]> files = parent != null ? new TreeMap<String, byte[]>(parent.files) : new TreeMap<String, byte[]>(); files.keySet().removeAll(removed); files.putAll(added); this.files = files; } public String getHash() { if (hash == null) { try { Charset utf8 = Charset.forName("UTF-8"); MessageDigest sha = MessageDigest.getInstance("SHA-1"); if (parent != null) { sha.update(new BigInteger(parent.getHash(), 16).toByteArray()); } sha.update(StringUtils.defaultString(message).getBytes(utf8)); sha.update((byte) (timestamp & 0xff)); sha.update((byte) ((timestamp >> 8) & 0xff)); sha.update((byte) ((timestamp >> 16) & 0xff)); sha.update((byte) ((timestamp >> 24) & 0xff)); sha.update((byte) ((timestamp >> 32) & 0xff)); sha.update((byte) ((timestamp >> 40) & 0xff)); sha.update((byte) ((timestamp >> 48) & 0xff)); sha.update((byte) ((timestamp >> 56) & 0xff)); for (Map.Entry<String, byte[]> e : files.entrySet()) { sha.update(e.getKey().getBytes(utf8)); sha.update(e.getValue()); } hash = javax.xml.bind.DatatypeConverter.printHexBinary(sha.digest()).toLowerCase(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-1 message digest mandated by JLS"); } } return hash; } } public static final class LogEntry { private final String hash; private final long timestamp; private final String message; private LogEntry(String hash, long timestamp, String message) { this.hash = hash; this.timestamp = timestamp; this.message = message; } public String getHash() { return hash; } public long getTimestamp() { return timestamp; } public String getMessage() { return message; } @Override public String toString() { return String.format("Commit %s%nDate: %tc%n%s%n", hash, timestamp, message); } } }