/* * The MIT License * * Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi, Yahoo! 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.model; import com.google.common.collect.ImmutableList; import com.infradna.tool.bridge_method_injector.WithBridgeMethods; import com.thoughtworks.xstream.converters.Converter; import com.thoughtworks.xstream.converters.MarshallingContext; import com.thoughtworks.xstream.converters.UnmarshallingContext; import com.thoughtworks.xstream.converters.basic.DateConverter; import com.thoughtworks.xstream.converters.collections.CollectionConverter; import com.thoughtworks.xstream.io.HierarchicalStreamReader; import com.thoughtworks.xstream.io.HierarchicalStreamWriter; import hudson.Util; import hudson.XmlFile; import hudson.BulkChange; import hudson.Extension; import hudson.model.listeners.ItemListener; import hudson.model.listeners.SaveableListener; import hudson.security.ACL; import hudson.security.ACLContext; import hudson.util.AtomicFileWriter; import hudson.util.HexBinaryConverter; import hudson.util.Iterators; import hudson.util.PersistedList; import hudson.util.RunList; import hudson.util.XStream2; import java.io.EOFException; import jenkins.model.FingerprintFacet; import jenkins.model.Jenkins; import jenkins.model.TransientFingerprintFacetFactory; import org.apache.commons.lang.StringUtils; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.util.AbstractCollection; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.Hashtable; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.TreeMap; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import org.acegisecurity.AccessDeniedException; import org.acegisecurity.Authentication; import org.xmlpull.v1.XmlPullParserException; /** * A file being tracked by Jenkins. * * <p> * Lifecycle is managed by {@link FingerprintMap}. * * @author Kohsuke Kawaguchi * @see FingerprintMap */ @ExportedBean public class Fingerprint implements ModelObject, Saveable { /** * Pointer to a {@link Build}. */ @ExportedBean(defaultVisibility=2) public static class BuildPtr { String name; final int number; public BuildPtr(String name, int number) { this.name = name; this.number = number; } public BuildPtr(Run run) { this( run.getParent().getFullName(), run.getNumber() ); } /** * Gets {@link Job#getFullName() the full name of the job}. * Such job could be since then removed, so there might not be a corresponding {@link Job}. * * @return A name of the job */ @Exported @Nonnull public String getName() { return name; } /** * Checks if the current user has permission to see this pointer. * @return {@code true} if the job exists and user has {@link Item#READ} permissions * or if the current user has {@link Jenkins#ADMINISTER} permissions. * If the job exists, but the current user has no permission to discover it, * {@code false} will be returned. * If the job has been deleted and the user has no {@link Jenkins#ADMINISTER} permissions, * it also returns {@code false} in order to avoid the job existence fact exposure. */ private boolean hasPermissionToDiscoverBuild() { // We expose the data to Jenkins administrators in order to // let them manage the data for deleted jobs (also works for SYSTEM) final Jenkins instance = Jenkins.getInstance(); if (instance.hasPermission(Jenkins.ADMINISTER)) { return true; } return canDiscoverItem(name); } void setName(String newName) { name = newName; } /** * Gets the {@link Job} that this pointer points to, * or null if such a job no longer exists. */ @WithBridgeMethods(value=AbstractProject.class, castRequired=true) public Job<?,?> getJob() { return Jenkins.getInstance().getItemByFullName(name, Job.class); } /** * Gets the project build number. * <p> * Such {@link Run} could be since then discarded. * @return A build number */ @Exported @Nonnull public int getNumber() { return number; } /** * Gets the {@link Job} that this pointer points to, * or null if such a job no longer exists. */ public Run getRun() { Job j = getJob(); if(j==null) return null; return j.getBuildByNumber(number); } private boolean isAlive() { return getRun()!=null; } /** * Returns true if {@link BuildPtr} points to the given run. */ public boolean is(Run r) { return r.getNumber()==number && r.getParent().getFullName().equals(name); } /** * Returns true if {@link BuildPtr} points to the given job. */ public boolean is(Job job) { return job.getFullName().equals(name); } /** * Returns true if {@link BuildPtr} points to the given job * or one of its subordinates. * * <p> * This is useful to check if an artifact in MavenModule * belongs to MavenModuleSet. */ public boolean belongsTo(Job job) { Item p = Jenkins.getInstance().getItemByFullName(name); while(p!=null) { if(p==job) return true; // go up the chain while we ItemGroup<? extends Item> parent = p.getParent(); if (!(parent instanceof Item)) { return false; } p = (Item) parent; } return false; } @Override public String toString() { return name + " #" + number; } } /** * Range of build numbers [start,end). Immutable. */ @ExportedBean(defaultVisibility=4) public static final class Range { final int start; final int end; public Range(int start, int end) { assert start<end; this.start = start; this.end = end; } @Exported public int getStart() { return start; } @Exported public int getEnd() { return end; } public boolean isSmallerThan(int i) { return end<=i; } public boolean isBiggerThan(int i) { return i<start; } public boolean includes(int i) { return start<=i && i<end; } public Range expandRight() { return new Range(start,end+1); } public Range expandLeft() { return new Range(start-1,end); } public boolean isAdjacentTo(Range that) { return this.end==that.start; } @Override public String toString() { return "["+start+","+end+")"; } /** * Returns true if two {@link Range}s can't be combined into a single range. */ public boolean isIndependent(Range that) { return this.end<that.start ||that.end<this.start; } /** * Returns true if two {@link Range}s do not share any common integer. */ public boolean isDisjoint(Range that) { return this.end<=that.start || that.end<=this.start; } /** * Returns true if this range only represents a single number. */ public boolean isSingle() { return end-1==start; } /** * If this range contains every int that's in the other range, return true */ public boolean contains(Range that) { return this.start<=that.start && that.end<=this.end; } /** * Returns the {@link Range} that combines two ranges. */ public Range combine(Range that) { assert !isIndependent(that); return new Range( Math.min(this.start,that.start), Math.max(this.end ,that.end )); } /** * Returns the {@link Range} that represents the intersection of the two. */ public Range intersect(Range that) { assert !isDisjoint(that); return new Range( Math.max(this.start, that.start), Math.min(this.end, that.end)); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Range that = (Range) o; return start == that.start && end == that.end; } @Override public int hashCode() { return 31 * start + end; } } /** * Set of {@link Range}s. Mutable. */ @ExportedBean(defaultVisibility=3) public static final class RangeSet { // sorted private final List<Range> ranges; public RangeSet() { this(new ArrayList<Range>()); } private RangeSet(List<Range> data) { this.ranges = data; } private RangeSet(Range initial) { this(); ranges.add(initial); } /** * List all numbers in this range set, in the ascending order. */ public Iterable<Integer> listNumbers() { final List<Range> ranges = getRanges(); return new Iterable<Integer>() { public Iterator<Integer> iterator() { return new Iterators.FlattenIterator<Integer,Range>(ranges) { protected Iterator<Integer> expand(Range range) { return Iterators.sequence(range.start,range.end).iterator(); } }; } }; } // /** // * List up builds. // */ // public <J extends Job<J,R>,R extends Run<J,R>> Iterable<R> listBuilds(final J job) { // return new Iterable<R>() { // public Iterator<R> iterator() { // return new Iterators.FilterIterator<R>(new AdaptedIterator<Integer,R>(listNumbers().iterator()) { // protected R adapt(Integer n) { // return job.getBuildByNumber(n); // } // }) { // protected boolean filter(R r) { // return r!=null; // } // }; // } // }; // } /** * List all numbers in this range set in the descending order. */ public Iterable<Integer> listNumbersReverse() { final List<Range> ranges = getRanges(); return new Iterable<Integer>() { public Iterator<Integer> iterator() { return new Iterators.FlattenIterator<Integer,Range>(Iterators.reverse(ranges)) { protected Iterator<Integer> expand(Range range) { return Iterators.reverseSequence(range.start,range.end).iterator(); } }; } }; } /** * Gets all the ranges. */ @Exported public synchronized List<Range> getRanges() { return new ArrayList<Range>(ranges); } /** * Expands the range set to include the given value. * If the set already includes this number, this will be a no-op. */ public synchronized void add(int n) { for( int i=0; i<ranges.size(); i++ ) { Range r = ranges.get(i); if(r.includes(n)) return; // already included if(r.end==n) { ranges.set(i,r.expandRight()); checkCollapse(i); return; } if(r.start==n+1) { ranges.set(i,r.expandLeft()); checkCollapse(i-1); return; } if(r.isBiggerThan(n)) { // needs to insert a single-value Range ranges.add(i,new Range(n,n+1)); return; } } ranges.add(new Range(n,n+1)); } public synchronized void addAll(int... n) { for (int i : n) add(i); } private void checkCollapse(int i) { if(i<0 || i==ranges.size()-1) return; Range lhs = ranges.get(i); Range rhs = ranges.get(i+1); if(lhs.isAdjacentTo(rhs)) { // collapsed Range r = new Range(lhs.start,rhs.end); ranges.set(i,r); ranges.remove(i+1); } } public synchronized boolean includes(int i) { for (Range r : ranges) { if(r.includes(i)) return true; } return false; } public synchronized void add(RangeSet that) { int lhs=0,rhs=0; while(lhs<this.ranges.size() && rhs<that.ranges.size()) { Range lr = this.ranges.get(lhs); Range rr = that.ranges.get(rhs); // no overlap if(lr.end<rr.start) { lhs++; continue; } if(rr.end<lr.start) { ranges.add(lhs,rr); lhs++; rhs++; continue; } // overlap. merge two Range m = lr.combine(rr); rhs++; // since ranges[lhs] is expanded, it might overlap with others in this.ranges while(lhs+1<this.ranges.size() && !m.isIndependent(this.ranges.get(lhs+1))) { m = m.combine(this.ranges.get(lhs+1)); this.ranges.remove(lhs+1); } this.ranges.set(lhs,m); } // if anything is left in that.ranges, add them all this.ranges.addAll(that.ranges.subList(rhs,that.ranges.size())); } /** * Updates this range set by the intersection of this range and the given range. * * @return true if this range set was modified as a result. */ public synchronized boolean retainAll(RangeSet that) { List<Range> intersection = new ArrayList<Range>(); int lhs=0,rhs=0; while(lhs<this.ranges.size() && rhs<that.ranges.size()) { Range lr = this.ranges.get(lhs); Range rr = that.ranges.get(rhs); if(lr.end<=rr.start) {// lr has no overlap with that.ranges lhs++; continue; } if(rr.end<=lr.start) {// rr has no overlap with this.ranges rhs++; continue; } // overlap. figure out the intersection Range v = lr.intersect(rr); intersection.add(v); // move on to the next pair if (lr.end<rr.end) { lhs++; } else { rhs++; } } boolean same = this.ranges.equals(intersection); if (!same) { this.ranges.clear(); this.ranges.addAll(intersection); return true; } else { return false; } } /** * Updates this range set by removing all the values in the given range set. * * @return true if this range set was modified as a result. */ public synchronized boolean removeAll(RangeSet that) { boolean modified = false; List<Range> sub = new ArrayList<Range>(); int lhs=0,rhs=0; while(lhs<this.ranges.size() && rhs<that.ranges.size()) { Range lr = this.ranges.get(lhs); Range rr = that.ranges.get(rhs); if(lr.end<=rr.start) {// lr has no overlap with that.ranges. lr stays sub.add(lr); lhs++; continue; } if(rr.end<=lr.start) {// rr has no overlap with this.ranges rhs++; continue; } // some overlap between lr and rr assert !lr.isDisjoint(rr); modified = true; if (rr.contains(lr)) { // lr completely removed by rr lhs++; continue; } // we want to look at A and B below, if they are non-null. // |------------| lr // |-----| rr // A B // // note that lr and rr could be something like or the other way around // |------------| lr // |------------| rr // A (no B) if (lr.start<rr.start) {// if A is non-empty, that will stay Range a = new Range(lr.start, rr.start); sub.add(a); } if (rr.end<lr.end) {// if B is non-empty // we still need to check that with that.ranges, so keep it in the place of lr. // how much of them will eventually stay is up to the remainder of that.ranges this.ranges.set(lhs,new Range(rr.end,lr.end)); rhs++; } else { // if B is empty, we are done considering lr lhs++; } } if (!modified) return false; // no changes // whatever that remains in lhs will survive sub.addAll(this.ranges.subList(lhs,this.ranges.size())); this.ranges.clear(); this.ranges.addAll(sub); return true; } @Override public synchronized String toString() { StringBuilder buf = new StringBuilder(); for (Range r : ranges) { if(buf.length()>0) buf.append(','); buf.append(r); } return buf.toString(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; return ranges.equals(((RangeSet) o).ranges); } @Override public int hashCode() { return ranges.hashCode(); } public synchronized boolean isEmpty() { return ranges.isEmpty(); } /** * Returns the smallest value in this range. * <p> * If this range is empty, this method throws an exception. */ public synchronized int min() { return ranges.get(0).start; } /** * Returns the largest value in this range. * <p> * If this range is empty, this method throws an exception. */ public synchronized int max() { return ranges.get(ranges.size()-1).end; } /** * Returns true if all the integers logically in this {@link RangeSet} * is smaller than the given integer. For example, {[1,3)} is smaller than 3, * but {[1,3),[100,105)} is not smaller than anything less than 105. * * Note that {} is smaller than any n. */ public synchronized boolean isSmallerThan(int n) { if(ranges.isEmpty()) return true; return ranges.get(ranges.size() - 1).isSmallerThan(n); } /** * Parses a {@link RangeSet} from a string like "1-3,5,7-9" */ public static RangeSet fromString(String list, boolean skipError) { RangeSet rs = new RangeSet(); // Reject malformed ranges like "1---10", "1,,,,3" etc. if (list.contains("--") || list.contains(",,")) { if (!skipError) { throw new IllegalArgumentException( String.format("Unable to parse '%s', expected correct notation M,N or M-N", list)); } // ignore malformed notation return rs; } String[] items = Util.tokenize(list,","); if(items.length > 1 && items.length <= StringUtils.countMatches(list, ",")) { if (!skipError) { throw new IllegalArgumentException( String.format("Unable to parse '%s', expected correct notation M,N or M-N", list)); } // ignore malformed notation like ",1,2" or "1,2," return rs; } for (String s : items) { s = s.trim(); // s is either single number or range "x-y". // note that the end range is inclusive in this notation, but not in the Range class try { if (s.isEmpty()) { if (!skipError) { throw new IllegalArgumentException( String.format("Unable to parse '%s', expected number", list)); } // ignore "" element continue; } if(s.contains("-")) { if(StringUtils.countMatches(s, "-") > 1) { if (!skipError) { throw new IllegalArgumentException(String.format( "Unable to parse '%s', expected correct notation M,N or M-N", list)); } // ignore malformed ranges like "-5-2" or "2-5-" continue; } String[] tokens = Util.tokenize(s,"-"); if (tokens.length == 2) { int left = Integer.parseInt(tokens[0]); int right = Integer.parseInt(tokens[1]); if(left < 0 || right < 0) { if (!skipError) { throw new IllegalArgumentException( String.format("Unable to parse '%s', expected number above zero", list)); } // ignore a range which starts or ends under zero like "-5-3" continue; } if(left > right) { if (!skipError) { throw new IllegalArgumentException(String.format( "Unable to parse '%s', expected string with a range M-N where M<N", list)); } // ignore inverse range like "10-5" continue; } rs.ranges.add(new Range(left, right+1)); } else { if (!skipError) { throw new IllegalArgumentException( String.format("Unable to parse '%s', expected string with a range M-N", list)); } // ignore malformed text like "1-10-50" continue; } } else { int n = Integer.parseInt(s); rs.ranges.add(new Range(n,n+1)); } } catch (NumberFormatException e) { if (!skipError) throw new IllegalArgumentException( String.format("Unable to parse '%s', expected number", list)); // ignore malformed text } } return rs; } static final class ConverterImpl implements Converter { private final Converter collectionConv; // used to convert ArrayList in it public ConverterImpl(Converter collectionConv) { this.collectionConv = collectionConv; } public boolean canConvert(Class type) { return type==RangeSet.class; } public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) { RangeSet src = (RangeSet) source; writer.setValue(serialize(src)); } static String serialize(RangeSet src) { StringBuilder buf = new StringBuilder(src.ranges.size()*10); for (Range r : src.ranges) { if(buf.length()>0) buf.append(','); if(r.isSingle()) buf.append(r.start); else buf.append(r.start).append('-').append(r.end-1); } return buf.toString(); } public Object unmarshal(HierarchicalStreamReader reader, final UnmarshallingContext context) { if(reader.hasMoreChildren()) { /* old format where <range> elements are nested like <range> <start>1337</start> <end>1479</end> </range> */ return new RangeSet((List<Range>)(collectionConv.unmarshal(reader,context))); } else { return RangeSet.fromString(reader.getValue(),true); } } } } @Extension public static final class ProjectRenameListener extends ItemListener { @Override public void onLocationChanged(final Item item, final String oldName, final String newName) { try (ACLContext _ = ACL.as(ACL.SYSTEM)) { locationChanged(item, oldName, newName); } } private void locationChanged(Item item, String oldName, String newName) { if (item instanceof AbstractProject) { AbstractProject p = Jenkins.getInstance().getItemByFullName(newName, AbstractProject.class); if (p != null) { RunList builds = p.getBuilds(); for (Object build : builds) { if (build instanceof AbstractBuild) { Collection<Fingerprint> fingerprints = ((AbstractBuild)build).getBuildFingerprints(); for (Fingerprint f : fingerprints) { try { f.rename(oldName, newName); } catch (IOException e) { logger.log(Level.WARNING, "Failed to update fingerprint record " + f.getFileName() + " when " + oldName + " was renamed to " + newName, e); } } } } } } } } private static final DateConverter DATE_CONVERTER = new DateConverter(); /** * Time when the fingerprint has been captured. */ private final @Nonnull Date timestamp; /** * Null if this fingerprint is for a file that's * apparently produced outside. */ private final @CheckForNull BuildPtr original; private final byte[] md5sum; private final String fileName; /** * Range of builds that use this file keyed by a job full name. */ private final Hashtable<String,RangeSet> usages = new Hashtable<String,RangeSet>(); PersistedList<FingerprintFacet> facets = new PersistedList<FingerprintFacet>(this); /** * Lazily computed immutable {@link FingerprintFacet}s created from {@link TransientFingerprintFacetFactory}. */ private transient volatile List<FingerprintFacet> transientFacets = null; public Fingerprint(@CheckForNull Run build, @Nonnull String fileName, @Nonnull byte[] md5sum) throws IOException { this(build==null ? null : new BuildPtr(build), fileName, md5sum); save(); } Fingerprint(@CheckForNull BuildPtr original, @Nonnull String fileName, @Nonnull byte[] md5sum) { this.original = original; this.md5sum = md5sum; this.fileName = fileName; this.timestamp = new Date(); } /** * The first build in which this file showed up, * if the file looked like it's created there. * <p> * This is considered as the "source" of this file, * or the owner, in the sense that this project "owns" * this file. * * @return null * if the file is apparently created outside Hudson or if the current * user has no permission to discover the job. */ @Exported public @CheckForNull BuildPtr getOriginal() { if (original != null && original.hasPermissionToDiscoverBuild()) { return original; } return null; } public @Nonnull String getDisplayName() { return fileName; } /** * The file name (like "foo.jar" without path). */ @Exported public @Nonnull String getFileName() { return fileName; } /** * Gets the MD5 hash string. */ @Exported(name="hash") public @Nonnull String getHashString() { return Util.toHexString(md5sum); } /** * Gets the timestamp when this record is created. */ @Exported public @Nonnull Date getTimestamp() { return timestamp; } /** * Gets the string that says how long since this build has scheduled. * * @return * string like "3 minutes" "1 day" etc. */ public @Nonnull String getTimestampString() { long duration = System.currentTimeMillis()-timestamp.getTime(); return Util.getPastTimeString(duration); } /** * Gets the build range set for the given job name. * * <p> * These builds of this job has used this file. * @return may be empty but not null. */ public @Nonnull RangeSet getRangeSet(String jobFullName) { RangeSet r = usages.get(jobFullName); if(r==null) r = new RangeSet(); return r; } public RangeSet getRangeSet(Job job) { return getRangeSet(job.getFullName()); } /** * Gets the sorted list of job names where this jar is used. */ public @Nonnull List<String> getJobs() { List<String> r = new ArrayList<String>(); r.addAll(usages.keySet()); Collections.sort(r); return r; } public @Nonnull Hashtable<String,RangeSet> getUsages() { return usages; } @ExportedBean(defaultVisibility=2) public static final class RangeItem { @Exported public final String name; @Exported public final RangeSet ranges; public RangeItem(String name, RangeSet ranges) { this.name = name; this.ranges = ranges; } } // this is for remote API @Exported(name="usage") public @Nonnull List<RangeItem> _getUsages() { List<RangeItem> r = new ArrayList<RangeItem>(); final Jenkins instance = Jenkins.getInstance(); for (Entry<String, RangeSet> e : usages.entrySet()) { final String itemName = e.getKey(); if (instance.hasPermission(Jenkins.ADMINISTER) || canDiscoverItem(itemName)) { r.add(new RangeItem(itemName, e.getValue())); } } return r; } /** * @deprecated Use {@link #addFor(hudson.model.Run)} */ @Deprecated public synchronized void add(@Nonnull AbstractBuild b) throws IOException { addFor((Run) b); } /** * Adds a usage reference to the build. * @param b {@link Run} to be referenced in {@link #usages} * @since 1.577 */ public synchronized void addFor(@Nonnull Run b) throws IOException { add(b.getParent().getFullName(), b.getNumber()); } /** * Records that a build of a job has used this file. */ public synchronized void add(@Nonnull String jobFullName, int n) throws IOException { addWithoutSaving(jobFullName, n); save(); } void addWithoutSaving(@Nonnull String jobFullName, int n) { synchronized(usages) { // TODO why not synchronized (this) like some, though not all, other accesses? RangeSet r = usages.get(jobFullName); if(r==null) { r = new RangeSet(); usages.put(jobFullName,r); } r.add(n); } } /** * Returns true if any of the builds recorded in this fingerprint * is still retained. * * <p> * This is used to find out old fingerprint records that can be removed * without losing too much information. */ public synchronized boolean isAlive() { if(original!=null && original.isAlive()) return true; for (Entry<String,RangeSet> e : usages.entrySet()) { Job j = Jenkins.getInstance().getItemByFullName(e.getKey(),Job.class); if(j==null) continue; Run firstBuild = j.getFirstBuild(); if(firstBuild==null) continue; int oldest = firstBuild.getNumber(); if(!e.getValue().isSmallerThan(oldest)) return true; } return false; } /** * Trim off references to non-existent builds and jobs, thereby making the fingerprint smaller. * * @return true * if this record was modified. * * @throws IOException Save failure */ public synchronized boolean trim() throws IOException { boolean modified = false; for (Entry<String,RangeSet> e : new Hashtable<String,RangeSet>(usages).entrySet()) {// copy because we mutate Job j = Jenkins.getInstance().getItemByFullName(e.getKey(),Job.class); if(j==null) {// no such job any more. recycle the record modified = true; usages.remove(e.getKey()); continue; } Run firstBuild = j.getFirstBuild(); if(firstBuild==null) {// no builds. recycle the whole record modified = true; usages.remove(e.getKey()); continue; } RangeSet cur = e.getValue(); // builds that are around without the keepLog flag on are normally clustered together (in terms of build #) // so our basic strategy is to discard everything up to the first ephemeral build, except those builds // that are marked as kept RangeSet kept = new RangeSet(); Run r = firstBuild; while (r!=null && r.isKeepLog()) { kept.add(r.getNumber()); r = r.getNextBuild(); } if (r==null) { // all the build records are permanently kept ones, so we'll just have to keep 'kept' out of whatever currently in 'cur' modified |= cur.retainAll(kept); } else { // otherwise we are ready to discard [0,r.number) except those marked as 'kept' RangeSet discarding = new RangeSet(new Range(-1,r.getNumber())); discarding.removeAll(kept); modified |= cur.removeAll(discarding); } if (cur.isEmpty()) { usages.remove(e.getKey()); modified = true; } } if (modified) { if (logger.isLoggable(Level.FINE)) { logger.log(Level.FINE, "Saving trimmed {0}", getFingerprintFile(md5sum)); } save(); } return modified; } /** * Gets the associated {@link FingerprintFacet}s. * * <p> * This method always return a non-empty collection, which is a synthetic collection. * It contains persisted {@link FingerprintFacet}s (those that are added explicitly, like * {@code fingerprint.getFacets().add(x)}), as well those {@linkplain TransientFingerprintFacetFactory that are transient}. * * <p> * Mutation to this collection will manipulate persisted set of {@link FingerprintFacet}s, and therefore regardless * of what you do, this collection will always contain a set of {@link FingerprintFacet}s that are added * by {@link TransientFingerprintFacetFactory}s. * * @since 1.421 */ public @Nonnull Collection<FingerprintFacet> getFacets() { if (transientFacets==null) { List<FingerprintFacet> transientFacets = new ArrayList<FingerprintFacet>(); for (TransientFingerprintFacetFactory fff : TransientFingerprintFacetFactory.all()) { fff.createFor(this,transientFacets); } this.transientFacets = ImmutableList.copyOf(transientFacets); } return new AbstractCollection<FingerprintFacet>() { @Override public Iterator<FingerprintFacet> iterator() { return Iterators.sequence(facets.iterator(), transientFacets.iterator()); } @Override public boolean add(FingerprintFacet e) { facets.add(e); return true; } @Override public boolean remove(Object o) { return facets.remove(o); } @Override public boolean contains(Object o) { return facets.contains(o) || transientFacets.contains(o); } @Override public int size() { return facets.size()+transientFacets.size(); } }; } /** * Sorts {@link FingerprintFacet}s by their timestamps. * @return Sorted list of {@link FingerprintFacet}s */ public @Nonnull Collection<FingerprintFacet> getSortedFacets() { List<FingerprintFacet> r = new ArrayList<FingerprintFacet>(getFacets()); Collections.sort(r,new Comparator<FingerprintFacet>() { public int compare(FingerprintFacet o1, FingerprintFacet o2) { long a = o1.getTimestamp(); long b = o2.getTimestamp(); if (a<b) return -1; if (a==b) return 0; return 1; } }); return r; } /** * Finds a facet of the specific type (including subtypes.) * @param <T> Class of the {@link FingerprintFacet} * @return First matching facet of the specified class * @since 1.556 */ public @CheckForNull <T extends FingerprintFacet> T getFacet(Class<T> type) { for (FingerprintFacet f : getFacets()) { if (type.isInstance(f)) return type.cast(f); } return null; } /** * Returns the actions contributed from {@link #getFacets()} */ public @Nonnull List<Action> getActions() { List<Action> r = new ArrayList<Action>(); for (FingerprintFacet ff : getFacets()) ff.createActions(r); return Collections.unmodifiableList(r); } /** * Save the settings to a file. * @throws IOException Save error */ public synchronized void save() throws IOException { if(BulkChange.contains(this)) return; long start=0; if(logger.isLoggable(Level.FINE)) start = System.currentTimeMillis(); File file = getFingerprintFile(md5sum); save(file); SaveableListener.fireOnChange(this, getConfigFile(file)); if(logger.isLoggable(Level.FINE)) logger.fine("Saving fingerprint "+file+" took "+(System.currentTimeMillis()-start)+"ms"); } void save(File file) throws IOException { if (facets.isEmpty()) { file.getParentFile().mkdirs(); // JENKINS-16301: fast path for the common case. AtomicFileWriter afw = new AtomicFileWriter(file); try { PrintWriter w = new PrintWriter(afw); w.println("<?xml version='1.0' encoding='UTF-8'?>"); w.println("<fingerprint>"); w.print(" <timestamp>"); w.print(DATE_CONVERTER.toString(timestamp)); w.println("</timestamp>"); if (original != null) { w.println(" <original>"); w.print(" <name>"); w.print(Util.xmlEscape(original.name)); w.println("</name>"); w.print(" <number>"); w.print(original.number); w.println("</number>"); w.println(" </original>"); } w.print(" <md5sum>"); w.print(Util.toHexString(md5sum)); w.println("</md5sum>"); w.print(" <fileName>"); w.print(Util.xmlEscape(fileName)); w.println("</fileName>"); w.println(" <usages>"); for (Map.Entry<String,RangeSet> e : usages.entrySet()) { w.println(" <entry>"); w.print(" <string>"); w.print(Util.xmlEscape(e.getKey())); w.println("</string>"); w.print(" <ranges>"); w.print(RangeSet.ConverterImpl.serialize(e.getValue())); w.println("</ranges>"); w.println(" </entry>"); } w.println(" </usages>"); w.println(" <facets/>"); w.print("</fingerprint>"); w.flush(); afw.commit(); } finally { afw.abort(); } } else { // Slower fallback that can persist facets. getConfigFile(file).write(this); } } /** * Update references to a renamed job in the fingerprint */ public synchronized void rename(String oldName, String newName) throws IOException { boolean touched = false; if (original != null) { if (original.getName().equals(oldName)) { original.setName(newName); touched = true; } } if (usages != null) { RangeSet r = usages.get(oldName); if (r != null) { usages.put(newName, r); usages.remove(oldName); touched = true; } } if (touched) { save(); } } public Api getApi() { return new Api(this); } /** * The file we save our configuration. */ private static @Nonnull XmlFile getConfigFile(@Nonnull File file) { return new XmlFile(XSTREAM,file); } /** * Determines the file name from md5sum. */ private static @Nonnull File getFingerprintFile(@Nonnull byte[] md5sum) { assert md5sum.length==16; return new File( Jenkins.getInstance().getRootDir(), "fingerprints/"+ Util.toHexString(md5sum,0,1)+'/'+Util.toHexString(md5sum,1,1)+'/'+Util.toHexString(md5sum,2,md5sum.length-2)+".xml"); } /** * Loads a {@link Fingerprint} from a file in the image. * @return Loaded {@link Fingerprint}. Null if the config file does not exist or * malformed. */ /*package*/ static @CheckForNull Fingerprint load(@Nonnull byte[] md5sum) throws IOException { return load(getFingerprintFile(md5sum)); } /*package*/ static @CheckForNull Fingerprint load(@Nonnull File file) throws IOException { XmlFile configFile = getConfigFile(file); if(!configFile.exists()) return null; long start=0; if(logger.isLoggable(Level.FINE)) start = System.currentTimeMillis(); try { Fingerprint f = (Fingerprint) configFile.read(); if(logger.isLoggable(Level.FINE)) logger.fine("Loading fingerprint "+file+" took "+(System.currentTimeMillis()-start)+"ms"); if (f.facets==null) f.facets = new PersistedList<FingerprintFacet>(f); for (FingerprintFacet facet : f.facets) facet._setOwner(f); return f; } catch (IOException e) { if(file.exists() && file.length()==0) { // Despite the use of AtomicFile, there are reports indicating that people often see // empty XML file, presumably either due to file system corruption (perhaps by sudden // power loss, etc.) or abnormal program termination. // generally we don't want to wipe out user data just because we can't load it, // but if the file size is 0, which is what's reported in HUDSON-2012, then it seems // like recovering it silently by deleting the file is not a bad idea. logger.log(Level.WARNING, "Size zero fingerprint. Disk corruption? {0}", configFile); file.delete(); return null; } String parseError = messageOfParseException(e); if (parseError != null) { logger.log(Level.WARNING, "Malformed XML in {0}: {1}", new Object[] {configFile, parseError}); file.delete(); return null; } logger.log(Level.WARNING, "Failed to load "+configFile,e); throw e; } } private static String messageOfParseException(Throwable t) { if (t instanceof XmlPullParserException || t instanceof EOFException) { return t.getMessage(); } Throwable t2 = t.getCause(); if (t2 != null) { return messageOfParseException(t2); } else { return null; } } @Override public String toString() { return "Fingerprint[original=" + original + ",hash=" + getHashString() + ",fileName=" + fileName + ",timestamp=" + DATE_CONVERTER.toString(timestamp) + ",usages=" + new TreeMap<String,RangeSet>(usages) + ",facets=" + facets + "]"; } /** * Checks if the current user can Discover the item. * If yes, it may be displayed as a text in Fingerprint UIs. * @param fullName Full name of the job * @return {@code true} if the user can discover the item */ private static boolean canDiscoverItem(@Nonnull final String fullName) { final Jenkins jenkins = Jenkins.getInstance(); // Fast check to avoid security context switches Item item = null; try { item = jenkins.getItemByFullName(fullName); } catch (AccessDeniedException ex) { // ignore, we will fall-back later } if (item != null) { return true; } // Probably it failed due to the missing Item.DISCOVER // We try to retrieve the job using SYSTEM user and to check permissions manually. final Authentication userAuth = Jenkins.getAuthentication(); try (ACLContext _ = ACL.as(ACL.SYSTEM)) { final Item itemBySystemUser = jenkins.getItemByFullName(fullName); if (itemBySystemUser == null) { return false; } // To get the item existence fact, a user needs Item.DISCOVER for the item // and Item.READ for all container folders. boolean canDiscoverTheItem = itemBySystemUser.getACL().hasPermission(userAuth, Item.DISCOVER); if (canDiscoverTheItem) { ItemGroup<?> current = itemBySystemUser.getParent(); do { if (current instanceof Item) { final Item i = (Item) current; current = i.getParent(); if (!i.getACL().hasPermission(userAuth, Item.READ)) { canDiscoverTheItem = false; } } else { current = null; } } while (canDiscoverTheItem && current != null); } return canDiscoverTheItem; } } private static final XStream2 XSTREAM = new XStream2(); /** * Provides the XStream instance this class is using for serialization. * * @return the XStream instance * @since 1.655 */ @Nonnull public static XStream2 getXStream() { return XSTREAM; } static { XSTREAM.alias("fingerprint",Fingerprint.class); XSTREAM.alias("range",Range.class); XSTREAM.alias("ranges",RangeSet.class); XSTREAM.registerConverter(new HexBinaryConverter(),10); XSTREAM.registerConverter(new RangeSet.ConverterImpl( new CollectionConverter(XSTREAM.getMapper()) { @Override protected Object createCollection(Class type) { return new ArrayList(); } } ),10); } private static final Logger logger = Logger.getLogger(Fingerprint.class.getName()); }