package edu.umd.cs.findbugs.updates; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.StringWriter; import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Random; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.Preferences; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.WillClose; import org.dom4j.Document; import org.dom4j.Element; import org.dom4j.io.SAXReader; import org.dom4j.io.XMLWriter; import edu.umd.cs.findbugs.DetectorFactoryCollection; import edu.umd.cs.findbugs.FindBugs; import edu.umd.cs.findbugs.Plugin; import edu.umd.cs.findbugs.SystemProperties; import edu.umd.cs.findbugs.Version; import edu.umd.cs.findbugs.util.MultiMap; import edu.umd.cs.findbugs.util.Util; import edu.umd.cs.findbugs.xml.OutputStreamXMLOutput; import edu.umd.cs.findbugs.xml.XMLUtil; public class UpdateChecker { public static final String PLUGIN_RELEASE_DATE_FMT = "MM/dd/yyyy hh:mm aa z"; private static final Logger LOGGER = Logger.getLogger(UpdateChecker.class.getName()); private static final String KEY_DISABLE_ALL_UPDATE_CHECKS = "noUpdateChecks"; private static final String KEY_REDIRECT_ALL_UPDATE_CHECKS = "redirectUpdateChecks"; private static final boolean ENV_FB_NO_UPDATE_CHECKS = System.getenv("FB_NO_UPDATE_CHECKS") != null; private final UpdateCheckCallback dfc; private final List<PluginUpdate> pluginUpdates = new CopyOnWriteArrayList<PluginUpdate>(); public UpdateChecker(UpdateCheckCallback dfc) { this.dfc = dfc; } public void checkForUpdates(Collection<Plugin> plugins, final boolean force) { if (updateChecksGloballyDisabled()) { dfc.pluginUpdateCheckComplete(pluginUpdates, force); return; } URI redirectUri = getRedirectURL(force); final CountDownLatch latch; if (redirectUri != null) { latch = new CountDownLatch(1); startUpdateCheckThread(redirectUri, plugins, latch); } else { MultiMap<URI,Plugin> pluginsByUrl = new MultiMap<URI, Plugin>(HashSet.class); for (Plugin plugin : plugins) { URI uri = plugin.getUpdateUrl(); if (uri == null) { logError(Level.FINE, "Not checking for updates for " + plugin.getShortDescription() + " - no update-url attribute in plugin XML file"); continue; } pluginsByUrl.add(uri, plugin); } latch = new CountDownLatch(pluginsByUrl.keySet().size()); for (URI uri : pluginsByUrl.keySet()) { startUpdateCheckThread(uri, pluginsByUrl.get(uri), latch); } } waitForCompletion(latch, force); } /** * @param force * @return */ public @CheckForNull URI getRedirectURL(final boolean force) { String redirect = dfc.getGlobalOption(KEY_REDIRECT_ALL_UPDATE_CHECKS); String sysprop = System.getProperty("findbugs.redirectUpdateChecks"); if (sysprop != null) redirect = sysprop; Plugin setter = dfc.getGlobalOptionSetter(KEY_REDIRECT_ALL_UPDATE_CHECKS); URI redirectUri = null; String pluginName = setter == null ? "<unknown plugin>" : setter.getShortDescription(); if (redirect != null && !redirect.trim().equals("")) { try { redirectUri = new URI(redirect); logError(Level.INFO, "Redirecting all plugin update checks to " + redirectUri + " (" + pluginName + ")"); } catch (URISyntaxException e) { String error = "Invalid update check redirect URI in " + pluginName + ": " + redirect; logError(Level.SEVERE, error); dfc.pluginUpdateCheckComplete(pluginUpdates, force); throw new IllegalStateException(error); } } return redirectUri; } private long dontWarnAgainUntil() { Preferences prefs = Preferences.userNodeForPackage(UpdateChecker.class); String oldSeen = prefs.get("last-plugin-update-seen", ""); if (oldSeen == null || oldSeen.equals("")) return 0; try { return Long.parseLong(oldSeen) + DONT_REMIND_WINDOW; } catch (Exception e) { return 0; } } static final long DONT_REMIND_WINDOW = 3L*24*60*60*1000; public boolean updatesHaveBeenSeenBefore(Collection<UpdateChecker.PluginUpdate> updates) { long now = System.currentTimeMillis(); Preferences prefs = Preferences.userNodeForPackage(UpdateChecker.class); String oldHash = prefs.get("last-plugin-update-hash", ""); String newHash = Integer.toString(buildPluginUpdateHash(updates)); if (oldHash.equals(newHash) && dontWarnAgainUntil() > now) { LOGGER.fine("Skipping update dialog because these updates have been seen before"); return true; } prefs.put("last-plugin-update-hash", newHash); prefs.put("last-plugin-update-seen", Long.toString(now)); return false; } private int buildPluginUpdateHash(Collection<UpdateChecker.PluginUpdate> updates) { HashSet<String> builder = new HashSet<String>(); for (UpdateChecker.PluginUpdate update : updates) { builder.add( update.getPlugin().getPluginId() + update.getVersion()); } return builder.hashCode(); } private void waitForCompletion(final CountDownLatch latch, final boolean force) { Util.runInDameonThread(new Runnable() { public void run() { if (DEBUG) System.out.println("Checking for version updates"); try { if (! latch.await(15, TimeUnit.SECONDS)) { logError(Level.INFO, "Update check timed out"); } dfc.pluginUpdateCheckComplete(pluginUpdates, force); } catch (Exception ignored) { assert true; } } }, "Plugin update checker"); } public boolean updateChecksGloballyDisabled() { return ENV_FB_NO_UPDATE_CHECKS || getPluginThatDisabledUpdateChecks() != null; } public String getPluginThatDisabledUpdateChecks() { String disable = dfc.getGlobalOption(KEY_DISABLE_ALL_UPDATE_CHECKS); Plugin setter = dfc.getGlobalOptionSetter(KEY_DISABLE_ALL_UPDATE_CHECKS); String pluginName = setter == null ? "<unknown plugin>" : setter.getShortDescription(); String disablingPlugin = null; if ("true".equalsIgnoreCase(disable)) { logError(Level.INFO, "Skipping update checks due to " + KEY_DISABLE_ALL_UPDATE_CHECKS + "=true set by " + pluginName); disablingPlugin = pluginName; } else if (disable != null && !"false".equalsIgnoreCase(disable)) { String error = "Unknown value '" + disable + "' for " + KEY_DISABLE_ALL_UPDATE_CHECKS + " in " + pluginName; logError(Level.SEVERE, error); throw new IllegalStateException(error); } return disablingPlugin; } private void startUpdateCheckThread(final URI url, final Collection<Plugin> plugins, final CountDownLatch latch) { if (url == null) { logError(Level.INFO, "Not checking for plugin updates w/ blank URL: " + getPluginNames(plugins)); return; } final String entryPoint = getEntryPoint(); if ((entryPoint.contains("edu.umd.cs.findbugs.FindBugsTestCase") || entryPoint.contains("edu.umd.cs.findbugs.cloud.appEngine.AbstractWebCloudTest")) && (url.getScheme().equals("http") || url.getScheme().equals("https"))) { LOGGER.fine("Skipping update check because we're running in FindBugsTestCase and using " + url.getScheme()); return; } Runnable r = new Runnable() { public void run() { try { actuallyCheckforUpdates(url, plugins, entryPoint); } catch (Exception e) { if (e instanceof IllegalStateException && e.getMessage().contains("Shutdown in progress")) return; logError(e, "Error doing update check at " + url); } finally { latch.countDown(); } } }; if (DEBUG) r.run(); else Util.runInDameonThread(r, "Check for updates"); } static final boolean DEBUG = SystemProperties.getBoolean("findbugs.updatecheck.debug"); /** protected for testing */ protected void actuallyCheckforUpdates(URI url, Collection<Plugin> plugins, String entryPoint) throws IOException { LOGGER.fine("Checking for updates at " + url + " for " + getPluginNames(plugins)); if (DEBUG) System.out.println(url); HttpURLConnection conn = (HttpURLConnection) url.toURL().openConnection(); conn.setDoInput(true); conn.setDoOutput(true); conn.setRequestMethod("POST"); conn.connect(); OutputStream out = conn.getOutputStream(); writeXml(out, plugins, entryPoint, true); // for debugging: if (DEBUG) { System.out.println("Sending"); writeXml(System.out, plugins, entryPoint, false); } int responseCode = conn.getResponseCode(); if (responseCode != 200) { logError(SystemProperties.ASSERTIONS_ENABLED ? Level.WARNING : Level.FINE, "Error checking for updates at " + url + ": " + responseCode + " - " + conn.getResponseMessage()); } else { parseUpdateXml(url, plugins, conn.getInputStream()); } conn.disconnect(); } /** protected for testing */ protected final void writeXml(OutputStream out, Collection<Plugin> plugins, String entryPoint, boolean finish) throws IOException { OutputStreamXMLOutput xmlOutput = new OutputStreamXMLOutput(out); try { xmlOutput.beginDocument(); xmlOutput.startTag("findbugs-invocation"); xmlOutput.addAttribute("version", Version.RELEASE); String applicationName = Version.getApplicationName(); if (applicationName == null || applicationName.equals("")) { int lastDot = entryPoint.lastIndexOf('.'); if (lastDot == -1) applicationName = entryPoint; else applicationName = entryPoint.substring(lastDot + 1); } xmlOutput.addAttribute("app-name", applicationName); String applicationVersion = Version.getApplicationVersion(); if (applicationVersion == null) applicationVersion = ""; xmlOutput.addAttribute("app-version", applicationVersion); xmlOutput.addAttribute("entry-point", entryPoint); xmlOutput.addAttribute("os", SystemProperties.getProperty("os.name", "")); xmlOutput.addAttribute("java-version", getMajorJavaVersion()); Locale locale = Locale.getDefault(); xmlOutput.addAttribute("language", locale.getLanguage()); xmlOutput.addAttribute("country", locale.getCountry()); xmlOutput.addAttribute("uuid", getUuid()); xmlOutput.stopTag(false); for (Plugin plugin : plugins) { xmlOutput.startTag("plugin"); xmlOutput.addAttribute("id", plugin.getPluginId()); xmlOutput.addAttribute("name", plugin.getShortDescription()); xmlOutput.addAttribute("version", plugin.getVersion()); Date date = plugin.getReleaseDate(); if (date != null) xmlOutput.addAttribute("release-date", Long.toString(date.getTime())); xmlOutput.stopTag(true); } xmlOutput.closeTag("findbugs-invocation"); xmlOutput.flush(); } finally { if (finish) xmlOutput.finish(); } } // package-private for testing @SuppressWarnings({ "unchecked" }) void parseUpdateXml(URI url, Collection<Plugin> plugins, @WillClose InputStream inputStream) { try { Document doc = new SAXReader().read(inputStream); if (DEBUG) { StringWriter stringWriter = new StringWriter(); XMLWriter xmlWriter = new XMLWriter(stringWriter); xmlWriter.write(doc); xmlWriter.close(); System.out.println("UPDATE RESPONSE: " + stringWriter.toString()); } List<Element> pluginEls = XMLUtil.selectNodes(doc, "fb-plugin-updates/plugin"); Map<String, Plugin> map = new HashMap<String, Plugin>(); for (Plugin p : plugins) map.put(p.getPluginId(), p); for (Element pluginEl : pluginEls) { String id = pluginEl.attributeValue("id"); Plugin plugin = map.get(id); if (plugin != null) { checkPlugin(pluginEl, plugin); } } } catch (Exception e) { logError(e, "Could not parse plugin version update for " + url); } finally { Util.closeSilently(inputStream); } } @SuppressWarnings({"unchecked"}) private void checkPlugin(Element pluginEl, Plugin plugin) { for (Element release : (List<Element>) pluginEl.elements("release")) { checkPluginRelease(plugin, release); } } private void checkPluginRelease(Plugin plugin, Element maxEl) { @CheckForNull Date updateDate = parseReleaseDate(maxEl); @CheckForNull Date installedDate = plugin.getReleaseDate(); if (updateDate != null && installedDate != null && updateDate.before(installedDate)) return; String version = maxEl.attributeValue("version"); if (version.equals(plugin.getVersion())) return; String url = maxEl.attributeValue("url"); String message = maxEl.element("message").getTextTrim(); pluginUpdates.add(new PluginUpdate(plugin, version, updateDate, url, message)); } // protected for testing protected void logError(Level level, String msg) { LOGGER.log(level, msg); } // protected for testing protected void logError(Exception e, String msg) { LOGGER.log(Level.INFO, msg, e); } private @CheckForNull Date parseReleaseDate(Element releaseEl) { SimpleDateFormat format = new SimpleDateFormat(PLUGIN_RELEASE_DATE_FMT); String dateStr = releaseEl.attributeValue("date"); if (dateStr == null) return null; try { return format.parse(dateStr); } catch (Exception e) { throw new IllegalArgumentException("Error parsing " + dateStr, e); } } private String getPluginNames(Collection<Plugin> plugins) { String text = ""; boolean first = true; for (Plugin plugin : plugins) { text = (first ? "" : ", ") + plugin.getShortDescription(); first = false; } return text; } private String getEntryPoint() { String lastFbClass = "<UNKNOWN>"; for (StackTraceElement s : Thread.currentThread().getStackTrace()) { String cls = s.getClassName(); if (cls.startsWith("edu.umd.cs.findbugs.")) { lastFbClass = cls; } } return lastFbClass; } /** Should only be used once */ private static Random random = new Random(); private static synchronized String getUuid() { try { Preferences prefs = Preferences.userNodeForPackage(UpdateChecker.class); long uuid = prefs.getLong("uuid", 0); if (uuid == 0) { uuid = random.nextLong(); prefs.putLong("uuid", uuid); } return Long.toString(uuid, 16); } catch (Throwable e) { return Long.toString(42, 16); } } private String getMajorJavaVersion() { String ver = SystemProperties.getProperty("java.version", ""); Matcher m = Pattern.compile("^\\d+\\.\\d+").matcher(ver); if (m.find()) { return m.group(); } return ""; } public static class PluginUpdate { private final Plugin plugin; private final String version; private final @CheckForNull Date date; private final @CheckForNull String url; private final @Nonnull String message; private PluginUpdate(Plugin plugin, String version, @CheckForNull Date date, @CheckForNull String url, @Nonnull String message) { this.plugin = plugin; this.version = version; this.date = date; this.url = url; this.message = message; } public Plugin getPlugin() { return plugin; } public String getVersion() { return version; } public @CheckForNull Date getDate() { return date; } public @CheckForNull String getUrl() { return url; } public @Nonnull String getMessage() { return message; } @Override public String toString() { SimpleDateFormat format = new SimpleDateFormat(PLUGIN_RELEASE_DATE_FMT); StringBuilder buf = new StringBuilder(); String name = getPlugin().isCorePlugin() ? "FindBugs" : "FindBugs plugin " + getPlugin().getShortDescription(); buf.append( name + " " + getVersion() ); if (date == null) buf.append(" has been released"); else buf.append(" was released " + format.format(date)); buf.append( " (you have " + getPlugin().getVersion() + ")"); buf.append("\n"); buf.append(" " + message.replaceAll("\n", "\n ")); if (url != null) buf.append("\nVisit " + url + " for details."); return buf.toString(); } } public static void main(String args[]) throws Exception { FindBugs.setNoAnalysis(); DetectorFactoryCollection dfc = DetectorFactoryCollection.instance(); UpdateChecker checker = dfc.getUpdateChecker(); if (checker.updateChecksGloballyDisabled()) System.out.println("Update checkes are globally disabled"); URI redirect = checker.getRedirectURL(false); if (redirect != null) System.out.println("All update checks redirected to " + redirect); checker.writeXml(System.out, dfc.plugins(), "UpdateChecker", true); } }