Temporarily include bndlib 1.47 for testing purposes (not yet on central)

git-svn-id: https://svn.apache.org/repos/asf/felix/trunk@1185095 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/FileInstallRepo.java b/bundleplugin/src/main/java/aQute/lib/deployer/FileInstallRepo.java
new file mode 100644
index 0000000..cf05f98
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/FileInstallRepo.java
@@ -0,0 +1,149 @@
+package aQute.lib.deployer;
+
+import java.io.*;
+import java.net.*;
+import java.util.*;
+import java.util.jar.*;
+import java.util.regex.*;
+
+import aQute.lib.osgi.*;
+import aQute.libg.reporter.*;
+import aQute.libg.version.*;
+
+public class FileInstallRepo extends FileRepo {
+
+	String group;
+	boolean dirty;
+	Reporter reporter;
+	Pattern              REPO_FILE   = Pattern
+    .compile("([-a-zA-z0-9_\\.]+)-([0-9\\.]+)\\.(jar|lib)");
+	
+    public void setProperties(Map<String, String> map) {
+    	super.setProperties(map);
+    	group = map.get("group");
+    }
+    public void setReporter(Reporter reporter) {
+    	super.setReporter(reporter);
+        this.reporter = reporter;
+    }
+
+    public File put(Jar jar) throws Exception {
+        dirty = true;
+        Manifest manifest = jar.getManifest();
+        if (manifest == null)
+            throw new IllegalArgumentException("No manifest in JAR: " + jar);
+
+        String bsn = manifest.getMainAttributes().getValue(
+                Analyzer.BUNDLE_SYMBOLICNAME);
+        if (bsn == null)
+            throw new IllegalArgumentException("No Bundle SymbolicName set");
+
+        Map<String, Map<String, String>> b = Processor.parseHeader(bsn, null);
+        if (b.size() != 1)
+            throw new IllegalArgumentException("Multiple bsn's specified " + b);
+
+        for (String key : b.keySet()) {
+            bsn = key;
+            if (!Verifier.SYMBOLICNAME.matcher(bsn).matches())
+                throw new IllegalArgumentException(
+                        "Bundle SymbolicName has wrong format: " + bsn);
+        }
+
+        String versionString = manifest.getMainAttributes().getValue(
+                Analyzer.BUNDLE_VERSION);
+        Version version;
+        if (versionString == null)
+            version = new Version();
+        else
+            version = new Version(versionString);
+
+        File dir;
+        if (group == null) {
+        	dir = getRoot();
+        } else {
+        	dir= new File(getRoot(), group);
+        	dir.mkdirs();
+        }
+        String fName = bsn + "-" + version.getMajor() + "."
+                + version.getMinor() + "." + version.getMicro() + ".jar";
+        File file = new File(dir, fName);
+
+        jar.write(file);
+        fireBundleAdded(jar, file);
+
+        file = new File(dir, bsn + "-latest.jar");
+        if (file.isFile() && file.lastModified() < jar.lastModified()) {
+            jar.write(file);
+        }
+        return file;
+    }
+    public boolean refresh() {
+        if ( dirty ) {
+            dirty = false;
+            return true;
+        } else 
+            return false;
+    }
+	@Override
+	public List<String> list(String regex) {
+	       Instruction pattern = null;
+	        if (regex != null)
+	            pattern = Instruction.getPattern(regex);
+
+	        String list[] = getRoot().list();
+	        List<String> result = new ArrayList<String>();
+	        for (String f : list) {
+                Matcher m = REPO_FILE.matcher(f);
+                if (!m.matches()) {
+                	continue;
+                }
+                String s = m.group(1);
+	            if (pattern == null || pattern.matches(s))
+	                result.add(s);
+	        }
+	        return result;
+	}
+	@Override
+	public File[] get(String bsn, String versionRange) throws MalformedURLException {
+	       // If the version is set to project, we assume it is not
+        // for us. A project repo will then get it.
+        if (versionRange != null && versionRange.equals("project"))
+            return null;
+
+        //
+        // The version range we are looking for can
+        // be null (for all) or a version range.
+        //
+        VersionRange range;
+        if (versionRange == null || versionRange.equals("latest")) {
+            range = new VersionRange("0");
+        } else
+            range = new VersionRange(versionRange);
+
+        //
+        // Iterator over all the versions for this BSN.
+        // Create a sorted map over the version as key
+        // and the file as URL as value. Only versions
+        // that match the desired range are included in
+        // this list.
+        //
+        File instances[] = getRoot().listFiles();
+        SortedMap<Version, File> versions = new TreeMap<Version, File>();
+        for (int i = 0; i < instances.length; i++) {
+            Matcher m = REPO_FILE.matcher(instances[i].getName());
+            if (m.matches() && m.group(1).equals(bsn)) {
+                String versionString = m.group(2);
+                Version version;
+                if (versionString.equals("latest"))
+                    version = new Version(Integer.MAX_VALUE);
+                else
+                    version = new Version(versionString);
+
+                if (range.includes(version))
+                    versions.put(version, instances[i]);
+            }
+        }
+        return (File[]) versions.values().toArray(new File[versions.size()]);
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/FileRepo.java b/bundleplugin/src/main/java/aQute/lib/deployer/FileRepo.java
new file mode 100644
index 0000000..0c701aa
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/FileRepo.java
@@ -0,0 +1,311 @@
+package aQute.lib.deployer;
+
+import java.io.*;
+import java.util.*;
+import java.util.jar.*;
+import java.util.regex.*;
+
+import aQute.bnd.service.*;
+import aQute.lib.io.*;
+import aQute.lib.osgi.*;
+import aQute.libg.reporter.*;
+import aQute.libg.version.*;
+
+public class FileRepo implements Plugin, RepositoryPlugin, Refreshable, RegistryPlugin {
+	public static String LOCATION = "location";
+	public static String READONLY = "readonly";
+	public static String NAME = "name";
+
+	File[] EMPTY_FILES = new File[0];
+	protected File root;
+	Registry registry;
+	boolean canWrite = true;
+	Pattern REPO_FILE = Pattern
+			.compile("([-a-zA-z0-9_\\.]+)-([0-9\\.]+|latest)\\.(jar|lib)");
+	Reporter reporter;
+	boolean dirty;
+	String name;
+
+	public FileRepo() {}
+	
+	public FileRepo(String name, File location, boolean canWrite) {
+		this.name = name;
+		this.root = location;
+		this.canWrite = canWrite;
+	}
+	
+	protected void init() throws Exception {
+		// for extensions
+	}
+	
+	public void setProperties(Map<String, String> map) {
+		String location = (String) map.get(LOCATION);
+		if (location == null)
+			throw new IllegalArgumentException(
+					"Location must be set on a FileRepo plugin");
+
+		root = new File(location);
+		if (!root.isDirectory())
+			throw new IllegalArgumentException(
+					"Repository is not a valid directory " + root);
+
+		String readonly = (String) map.get(READONLY);
+		if (readonly != null && Boolean.valueOf(readonly).booleanValue())
+			canWrite = false;
+
+		name = (String) map.get(NAME);
+	}
+
+	/**
+	 * Get a list of URLs to bundles that are constrained by the bsn and
+	 * versionRange.
+	 */
+	public File[] get(String bsn, String versionRange)
+			throws Exception {
+		init();
+		
+		// If the version is set to project, we assume it is not
+		// for us. A project repo will then get it.
+		if (versionRange != null && versionRange.equals("project"))
+			return null;
+
+		//
+		// Check if the entry exists
+		//
+		File f = new File(root, bsn);
+		if (!f.isDirectory())
+			return null;
+
+		//
+		// The version range we are looking for can
+		// be null (for all) or a version range.
+		//
+		VersionRange range;
+		if (versionRange == null || versionRange.equals("latest")) {
+			range = new VersionRange("0");
+		} else
+			range = new VersionRange(versionRange);
+
+		//
+		// Iterator over all the versions for this BSN.
+		// Create a sorted map over the version as key
+		// and the file as URL as value. Only versions
+		// that match the desired range are included in
+		// this list.
+		//
+		File instances[] = f.listFiles();
+		SortedMap<Version, File> versions = new TreeMap<Version, File>();
+		for (int i = 0; i < instances.length; i++) {
+			Matcher m = REPO_FILE.matcher(instances[i].getName());
+			if (m.matches() && m.group(1).equals(bsn)) {
+				String versionString = m.group(2);
+				Version version;
+				if (versionString.equals("latest"))
+					version = new Version(Integer.MAX_VALUE);
+				else
+					version = new Version(versionString);
+
+				if (range.includes(version)
+						|| versionString.equals(versionRange))
+					versions.put(version, instances[i]);
+			}
+		}
+
+		File[] files = (File[]) versions.values().toArray(EMPTY_FILES);
+		if ("latest".equals(versionRange) && files.length > 0) {
+			return new File[] { files[files.length - 1] };
+		}
+		return files;
+	}
+
+	public boolean canWrite() {
+		return canWrite;
+	}
+
+	public File put(Jar jar) throws Exception {
+		init();
+		dirty = true;
+
+		Manifest manifest = jar.getManifest();
+		if (manifest == null)
+			throw new IllegalArgumentException("No manifest in JAR: " + jar);
+
+		String bsn = manifest.getMainAttributes().getValue(
+				Analyzer.BUNDLE_SYMBOLICNAME);
+		if (bsn == null)
+			throw new IllegalArgumentException("No Bundle SymbolicName set");
+
+		Map<String, Map<String, String>> b = Processor.parseHeader(bsn, null);
+		if (b.size() != 1)
+			throw new IllegalArgumentException("Multiple bsn's specified " + b);
+
+		for (String key : b.keySet()) {
+			bsn = key;
+			if (!Verifier.SYMBOLICNAME.matcher(bsn).matches())
+				throw new IllegalArgumentException(
+						"Bundle SymbolicName has wrong format: " + bsn);
+		}
+
+		String versionString = manifest.getMainAttributes().getValue(
+				Analyzer.BUNDLE_VERSION);
+		Version version;
+		if (versionString == null)
+			version = new Version();
+		else
+			version = new Version(versionString);
+
+		File dir = new File(root, bsn);
+		dir.mkdirs();
+		String fName = bsn + "-" + version.getMajor() + "."
+				+ version.getMinor() + "." + version.getMicro() + ".jar";
+		File file = new File(dir, fName);
+
+		reporter.trace("Updating " + file.getAbsolutePath());
+		if (!file.exists() || file.lastModified() < jar.lastModified()) {
+			jar.write(file);
+			reporter.progress("Updated " + file.getAbsolutePath());
+			fireBundleAdded(jar, file);
+		} else {
+			reporter.progress("Did not update " + jar
+					+ " because repo has a newer version");
+			reporter.trace("NOT Updating " + fName + " (repo is newer)");
+		}
+
+		File latest = new File(dir, bsn + "-latest.jar");
+		if (latest.exists() && latest.lastModified() < jar.lastModified()) {
+			jar.write(latest);
+			file = latest;
+		}
+		
+		return file;
+	}
+
+	protected void fireBundleAdded(Jar jar, File file) {
+		if (registry == null)
+			return;
+		List<RepositoryListenerPlugin> listeners = registry.getPlugins(RepositoryListenerPlugin.class);
+		for (RepositoryListenerPlugin listener : listeners) {
+			try {
+				listener.bundleAdded(this, jar, file);
+			} catch (Exception e) {
+				if (reporter != null)
+					reporter.warning("Repository listener threw an unexpected exception: %s", e);
+			}
+		}
+	}
+
+	public void setLocation(String string) {
+		root = new File(string);
+		if (!root.isDirectory())
+			throw new IllegalArgumentException("Invalid repository directory");
+	}
+
+	public void setReporter(Reporter reporter) {
+		this.reporter = reporter;
+	}
+
+	public List<String> list(String regex) throws Exception {
+		init();
+		Instruction pattern = null;
+		if (regex != null)
+			pattern = Instruction.getPattern(regex);
+
+		List<String> result = new ArrayList<String>();
+		if (root == null) {
+			if (reporter != null) reporter.error("FileRepo root directory is not set.");
+		} else {
+			File[] list = root.listFiles();
+			if (list != null) {
+				for (File f : list) {
+					if (!f.isDirectory()) continue; // ignore non-directories
+					String fileName = f.getName();
+					if (fileName.charAt(0) == '.') continue; // ignore hidden files
+					if (pattern == null || pattern.matches(fileName))
+						result.add(fileName);
+				}
+			} else 
+				if ( reporter != null)
+					reporter.error("FileRepo root directory (%s) does not exist", root);
+		}
+
+		return result;
+	}
+
+	public List<Version> versions(String bsn) throws Exception {
+		init();
+		File dir = new File(root, bsn);
+		if (dir.isDirectory()) {
+			String versions[] = dir.list();
+			List<Version> list = new ArrayList<Version>();
+			for (String v : versions) {
+				Matcher m = REPO_FILE.matcher(v);
+				if (m.matches()) {
+					String version = m.group(2);
+					if (version.equals("latest"))
+						version = "99";
+					list.add(new Version(version));
+				}
+			}
+			return list;
+		}
+		return null;
+	}
+
+	public String toString() {
+		return String
+				.format("%-40s r/w=%s", root.getAbsolutePath(), canWrite());
+	}
+
+	public File getRoot() {
+		return root;
+	}
+
+	public boolean refresh() {
+		if (dirty) {
+			dirty = false;
+			return true;
+		} else
+			return false;
+	}
+
+	public String getName() {
+		if (name == null) {
+			return toString();
+		}
+		return name;
+	}
+	public File get(String bsn, String version, Strategy strategy, Map<String,String> properties) throws Exception {
+		if ( version == null)
+			version = "0.0.0";
+		
+		if ( strategy == Strategy.EXACT) {				
+			VersionRange vr = new VersionRange(version);
+			if ( vr.isRange())
+				return null;
+			
+			File file = IO.getFile(root, bsn + "/" + version +"/" + bsn + "-" + version + ".jar");
+			if ( file.isFile())
+				return file;
+			else
+				return null;
+
+		}
+		File[] files = get(bsn, version);
+		if ( files == null || files.length == 0)
+			return null;
+		
+		if (files.length >= 0) {
+			switch (strategy) {
+			case LOWEST:
+				return files[0];
+			case HIGHEST:
+				return files[files.length - 1];
+			}
+		}
+		return null;
+	}
+
+	public void setRegistry(Registry registry) {
+		this.registry = registry;
+	}
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/AbstractBaseOBR.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/AbstractBaseOBR.java
new file mode 100644
index 0000000..c6a1af5
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/AbstractBaseOBR.java
@@ -0,0 +1,564 @@
+package aQute.lib.deployer.obr;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Properties;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.StringTokenizer;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+import org.xml.sax.SAXException;
+
+import aQute.bnd.build.ResolverMode;
+import aQute.bnd.service.OBRIndexProvider;
+import aQute.bnd.service.OBRResolutionMode;
+import aQute.bnd.service.Plugin;
+import aQute.bnd.service.Registry;
+import aQute.bnd.service.RegistryPlugin;
+import aQute.bnd.service.RemoteRepositoryPlugin;
+import aQute.bnd.service.ResourceHandle;
+import aQute.bnd.service.ResourceHandle.Location;
+import aQute.lib.filter.Filter;
+import aQute.lib.osgi.Jar;
+import aQute.libg.generics.Create;
+import aQute.libg.reporter.Reporter;
+import aQute.libg.version.Version;
+import aQute.libg.version.VersionRange;
+
+/**
+ * Abstract base class for OBR-based repositories.
+ * 
+ * <p>
+ * The repository implementation is read-only by default. To implement a writable
+ * repository, subclasses should override {@link #canWrite()} and {@link #put(Jar)}.
+ * 
+ * @author Neil Bartlett
+ *
+ */
+public abstract class AbstractBaseOBR implements RegistryPlugin, Plugin, RemoteRepositoryPlugin, OBRIndexProvider {
+	
+	public static final String PROP_NAME = "name";
+	public static final String PROP_RESOLUTION_MODE = "mode";
+	public static final String PROP_RESOLUTION_MODE_ANY = "any";
+	
+	public static final String REPOSITORY_FILE_NAME = "repository.xml";
+	
+	protected Registry registry;
+	protected Reporter reporter;
+	protected String name = this.getClass().getName();
+	protected Set<OBRResolutionMode> supportedModes = EnumSet.allOf(OBRResolutionMode.class);
+
+	private boolean initialised = false;
+	private final Map<String, SortedMap<Version, Resource>> pkgResourceMap = new HashMap<String, SortedMap<Version, Resource>>();
+	private final Map<String, SortedMap<Version, Resource>> bsnMap = new HashMap<String, SortedMap<Version, Resource>>();
+	
+	protected abstract File getCacheDirectory();
+
+	protected void addResourceToIndex(Resource resource) {
+		addBundleSymbolicNameToIndex(resource);
+		addPackagesToIndex(resource);
+	}
+
+	protected synchronized void reset() {
+		initialised = false;
+	}
+
+	/**
+	 * Initialise the indexes prior to main initialisation of internal
+	 * data structures. This implementation does nothing, but subclasses
+	 * may override if they need to perform such initialisation.
+	 * @throws Exception 
+	 */
+	protected void initialiseIndexes() throws Exception {
+	}
+
+	protected final synchronized void init() throws Exception {
+		if (!initialised) {
+			bsnMap.clear();
+			pkgResourceMap.clear();
+			
+			initialiseIndexes();
+			
+			IResourceListener listener = new IResourceListener() {
+				public boolean processResource(Resource resource) {
+					addResourceToIndex(resource);
+					return true;
+				}
+			};
+			Collection<URL> indexes = getOBRIndexes();
+			for (URL indexLocation : indexes) {
+				try {
+					InputStream stream = indexLocation.openStream();
+					readIndex(indexLocation.toString(), stream, listener);
+				} catch (Exception e) {
+					e.printStackTrace();
+					reporter.error("Unable to read index at URL '%s'.", indexLocation);
+				}
+			}
+			
+			initialised = true;
+		}
+	}
+	
+	public void setRegistry(Registry registry) {
+		this.registry = registry;
+	}
+
+	public void setProperties(Map<String, String> map) {
+		if (map.containsKey(PROP_NAME))
+			name = map.get(PROP_NAME);
+		
+		if (map.containsKey(PROP_RESOLUTION_MODE)) {
+			supportedModes = EnumSet.noneOf(OBRResolutionMode.class);
+			StringTokenizer tokenizer = new StringTokenizer(map.get(PROP_RESOLUTION_MODE), ",");
+			while (tokenizer.hasMoreTokens()) {
+				String token = tokenizer.nextToken().trim();
+				if (PROP_RESOLUTION_MODE_ANY.equalsIgnoreCase(token))
+					supportedModes = EnumSet.allOf(OBRResolutionMode.class);
+				else {
+					try {
+						supportedModes.add(OBRResolutionMode.valueOf(token));
+					} catch (Exception e) {
+						if (reporter != null) reporter.error("Unknown OBR resolution mode: " + token);
+					}
+				}
+			}
+		}
+	}
+	
+	public File[] get(String bsn, String range) throws Exception {
+		ResourceHandle[] handles = getHandles(bsn, range);
+		
+		return requestAll(handles);
+	}
+	
+	protected static File[] requestAll(ResourceHandle[] handles) throws IOException {
+		File[] result = (handles == null) ? new File[0] : new File[handles.length];
+		for (int i = 0; i < result.length; i++) {
+			result[i] = handles[i].request();
+		}
+		return result;
+	}
+
+	protected ResourceHandle[] getHandles(String bsn, String rangeStr) throws Exception {
+		init();
+		
+		// If the range is set to "project", we cannot resolve it.
+		if ("project".equals(rangeStr))
+			return null;
+		
+		
+		SortedMap<Version, Resource> versionMap = bsnMap.get(bsn);
+		if (versionMap == null || versionMap.isEmpty())
+			return null;
+		List<Resource> resources = narrowVersionsByVersionRange(versionMap, rangeStr);
+		List<ResourceHandle> handles = mapResourcesToHandles(resources);
+		
+		return (ResourceHandle[]) handles.toArray(new ResourceHandle[handles.size()]);
+	}
+	
+	public void setReporter(Reporter reporter) {
+		this.reporter = reporter;
+	}
+	
+	public File get(String bsn, String range, Strategy strategy, Map<String, String> properties) throws Exception {
+		ResourceHandle handle = getHandle(bsn, range, strategy, properties);
+		return handle != null ? handle.request() : null;
+	}
+	
+	public ResourceHandle getHandle(String bsn, String range, Strategy strategy, Map<String, String> properties) throws Exception {
+		ResourceHandle result;
+		if (bsn != null)
+			result = resolveBundle(bsn, range, strategy);
+		else {
+			String pkgName = properties.get(CapabilityType.PACKAGE.getTypeName());
+			
+			String modeName = properties.get(CapabilityType.MODE.getTypeName());
+			ResolverMode mode = (modeName != null) ? ResolverMode.valueOf(modeName) : null;
+			
+			if (pkgName != null)
+				result = resolvePackage(pkgName, range, strategy, mode, properties);
+			else
+				throw new IllegalArgumentException("Cannot resolve bundle: neither bsn nor package specified.");
+		}
+		return result;
+	}
+
+	public boolean canWrite() {
+		return false;
+	}
+
+	public File put(Jar jar) throws Exception {
+		throw new UnsupportedOperationException("Read-only repository.");
+	}
+
+	public List<String> list(String regex) throws Exception {
+		init();
+		Pattern pattern = regex != null ? Pattern.compile(regex) : null;
+		List<String> result = new LinkedList<String>();
+		
+		for (String bsn : bsnMap.keySet()) {
+			if (pattern == null || pattern.matcher(bsn).matches())
+				result.add(bsn);
+		}
+		
+		return result;
+	}
+
+	public List<Version> versions(String bsn) throws Exception {
+		init();
+		SortedMap<Version, Resource> versionMap = bsnMap.get(bsn);
+		List<Version> list;
+		if (versionMap != null) {
+			list = new ArrayList<Version>(versionMap.size());
+			list.addAll(versionMap.keySet());
+		} else {
+			list = Collections.emptyList();
+		}
+		return list;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	void addBundleSymbolicNameToIndex(Resource resource) {
+		String bsn = resource.getSymbolicName();
+		Version version;
+		String versionStr = resource.getVersion();
+		try {
+			version = new Version(versionStr);
+		} catch (Exception e) {
+			version = new Version("0.0.0");
+		}
+		SortedMap<Version, Resource> versionMap = bsnMap.get(bsn);
+		if (versionMap == null) {
+			versionMap = new TreeMap<Version, Resource>();
+			bsnMap.put(bsn, versionMap);
+		}
+		versionMap.put(version, resource);
+	}
+
+	void addPackagesToIndex(Resource resource) {
+		for (Capability capability : resource.getCapabilities()) {
+			if (CapabilityType.PACKAGE.getTypeName().equals(capability.getName())) {
+				String pkgName = null;
+				String versionStr = null;
+				
+				for (Property prop : capability.getProperties()) {
+					if (Property.PACKAGE.equals(prop.getName()))
+						pkgName = prop.getValue();
+					else if (Property.VERSION.equals(prop.getName()))
+						versionStr = prop.getValue();
+				}
+				
+				Version version;
+				try {
+					version = new Version(versionStr);
+				} catch (Exception e) {
+					version = new Version("0.0.0");
+				}
+				
+				if (pkgName != null) {
+					SortedMap<Version, Resource> versionMap = pkgResourceMap.get(pkgName);
+					if (versionMap == null) {
+						versionMap = new TreeMap<Version, Resource>();
+						pkgResourceMap.put(pkgName, versionMap);
+					}
+					versionMap.put(version, resource);
+				}
+			}
+		}
+	}
+
+	/**
+	 * @return Whether to continue parsing other indexes
+	 * @throws IOException 
+	 */
+	boolean readIndex(String baseUrl, InputStream stream, IResourceListener listener) throws ParserConfigurationException, SAXException, IOException {
+		SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+		SAXParser parser = parserFactory.newSAXParser();
+		try {
+			parser.parse(stream, new OBRSAXHandler(baseUrl, listener));
+			return true;
+		} catch (StopParseException e) {
+			return false;
+		} finally {
+			stream.close();
+		}
+	}
+
+	List<Resource> narrowVersionsByFilter(String pkgName, SortedMap<Version, Resource> versionMap, Filter filter) {
+		List<Resource> result = new ArrayList<Resource>(versionMap.size());
+		
+		Dictionary<String, String> dict = new Hashtable<String, String>();
+		dict.put("package", pkgName);
+		
+		for (Version version : versionMap.keySet()) {
+			dict.put("version", version.toString());
+			if (filter.match(dict))
+				result.add(versionMap.get(version));
+		}
+		
+		return result;
+	}
+
+	List<Resource> narrowVersionsByVersionRange(SortedMap<Version, Resource> versionMap, String rangeStr) {
+		List<Resource> result;
+		if ("latest".equals(rangeStr)) {
+			Version highest = versionMap.lastKey();
+			result = Create.list(new Resource[] { versionMap.get(highest) });
+		} else {
+			VersionRange range = rangeStr != null ? new VersionRange(rangeStr) : null;
+			
+			// optimisation: skip versions definitely less than the range
+			if (range != null && range.getLow() != null)
+				versionMap = versionMap.tailMap(range.getLow());
+			
+			result = new ArrayList<Resource>(versionMap.size());
+			for (Version version : versionMap.keySet()) {
+				if (range == null || range.includes(version))
+					result.add(versionMap.get(version));
+				
+				// optimisation: skip versions definitely higher than the range
+				if (range != null && range.isRange() && version.compareTo(range.getHigh()) >= 0)
+					break;
+			}
+		}
+		return result;
+	}
+	
+	void filterResourcesByResolverMode(Collection<Resource> resources, ResolverMode mode) {
+		assert mode != null;
+		
+		Properties modeCapability = new Properties();
+		modeCapability.setProperty(CapabilityType.MODE.getTypeName(), mode.name());
+		
+		for (Iterator<Resource> iter = resources.iterator(); iter.hasNext(); ) {
+			Resource resource = iter.next();
+			
+			Require modeRequire = resource.findRequire(CapabilityType.MODE.getTypeName());
+			if (modeRequire == null)
+				continue;
+			else if (modeRequire.getFilter() == null)
+				iter.remove();
+			else {
+				try {
+					Filter filter = new Filter(modeRequire.getFilter());
+					if (!filter.match(modeCapability))
+						iter.remove();
+				} catch (IllegalArgumentException e) {
+					if (reporter != null)
+						reporter.error("Error parsing mode filter requirement on resource %s: %s", resource.getUrl(), modeRequire.getFilter());
+					iter.remove();
+				}
+			}
+		}
+	}
+	
+	List<ResourceHandle> mapResourcesToHandles(Collection<Resource> resources) throws Exception {
+		List<ResourceHandle> result = new ArrayList<ResourceHandle>(resources.size());
+		
+		for (Resource resource : resources) {
+			ResourceHandle handle = mapResourceToHandle(resource);
+			if (handle != null)
+				result.add(handle);
+		}
+		
+		return result;
+	}
+	
+	ResourceHandle mapResourceToHandle(Resource resource) throws Exception {
+		ResourceHandle result = null;
+		
+		URLResourceHandle handle ;
+		try {
+			handle = new URLResourceHandle(resource.getUrl(), resource.getBaseUrl(), getCacheDirectory());
+		} catch (FileNotFoundException e) {
+			throw new FileNotFoundException("Broken link in repository index: " + e.getMessage());
+		}
+		if (handle.getLocation() == Location.local || getCacheDirectory() != null)
+			result = handle;
+		
+		return result;
+	}
+
+	ResourceHandle resolveBundle(String bsn, String rangeStr, Strategy strategy) throws Exception {
+		if (rangeStr == null) rangeStr = "0.0.0";
+		
+		if (strategy == Strategy.EXACT) {
+			return findExactMatch(bsn, rangeStr, bsnMap);
+		}
+		
+		ResourceHandle[] handles = getHandles(bsn, rangeStr);
+		ResourceHandle selected;
+		if (handles == null || handles.length == 0)
+			selected = null;
+		else {
+			switch(strategy) {
+			case LOWEST:
+				selected = handles[0];
+				break;
+			default:
+				selected = handles[handles.length - 1];
+			}
+		}
+		return selected;
+	}
+
+	ResourceHandle resolvePackage(String pkgName, String rangeStr, Strategy strategy, ResolverMode mode, Map<String, String> props) throws Exception {
+		init();
+		if (rangeStr == null) rangeStr = "0.0.0";
+		
+		SortedMap<Version, Resource> versionMap = pkgResourceMap.get(pkgName);
+		if (versionMap == null)
+			return null;
+		
+		// Was a filter expression supplied?
+		Filter filter = null;
+		String filterStr = props.get("filter");
+		if (filterStr != null) {
+			filter = new Filter(filterStr);
+		}
+		
+		// Narrow the resources by version range string or filter.
+		List<Resource> resources;
+		if (filter != null)
+			resources = narrowVersionsByFilter(pkgName, versionMap, filter);
+		else
+			resources = narrowVersionsByVersionRange(versionMap, rangeStr);
+		
+		// Remove resources that are invalid for the current resolution mode
+		if (mode != null)
+			filterResourcesByResolverMode(resources, mode);
+		
+		// Select the most suitable one
+		Resource selected;
+		if (resources == null || resources.isEmpty())
+			selected = null;
+		else {
+			switch (strategy) {
+			case LOWEST:
+				selected = resources.get(0);
+				break;
+			default:
+				selected = resources.get(resources.size() - 1);
+			}
+			expandPackageUses(pkgName, selected, props);
+		}
+		return selected != null ? mapResourceToHandle(selected) : null;
+	}
+
+	void expandPackageUses(String pkgName, Resource resource, Map<String, String> props) {
+		List<String> internalUses = new LinkedList<String>();
+		Map<String, Require> externalUses = new HashMap<String, Require>();
+		
+		internalUses.add(pkgName);
+		
+		Capability capability = resource.findPackageCapability(pkgName);
+		Property usesProp = capability.findProperty(Property.USES);
+		if (usesProp != null) {
+			StringTokenizer tokenizer = new StringTokenizer(usesProp.getValue(), ",");
+			while (tokenizer.hasMoreTokens()) {
+				String usesPkgName = tokenizer.nextToken();
+				Capability usesPkgCap = resource.findPackageCapability(usesPkgName);
+				if (usesPkgCap != null)
+					internalUses.add(usesPkgName);
+				else {
+					Require require = resource.findPackageRequire(usesPkgName);
+					if (require != null)
+						externalUses.put(usesPkgName, require);
+				}
+			}
+		}
+		props.put("packages", listToString(internalUses));
+		props.put("import-uses", formatPackageRequires(externalUses));
+	}
+	
+	String listToString(List<?> list) {
+		StringBuilder builder = new StringBuilder();
+		
+		int count = 0;
+		for (Object item : list) {
+			if (count++ > 0) builder.append(',');
+			builder.append(item);
+		}
+		
+		return builder.toString();
+	}
+
+	String formatPackageRequires(Map<String, Require> externalUses) {
+		StringBuilder builder = new StringBuilder();
+		
+		int count = 0;
+		for (Entry<String, Require> entry : externalUses.entrySet()) {
+			String pkgName = entry.getKey();
+			String filter = entry.getValue().getFilter();
+
+			if (count++ > 0)
+				builder.append(',');
+			builder.append(pkgName);
+			builder.append(";filter='");
+			builder.append(filter);
+			builder.append('\'');
+		}
+		
+		return builder.toString();
+	}
+
+	ResourceHandle findExactMatch(String identity, String version, Map<String, SortedMap<Version, Resource>> resourceMap) throws Exception {
+		Resource resource;
+		VersionRange range = new VersionRange(version);
+		if (range.isRange())
+			return null;
+		
+		SortedMap<Version, Resource> versions = resourceMap.get(identity);
+		resource = versions.get(range.getLow());
+		
+		return mapResourceToHandle(resource);
+	}
+	
+	/**
+	 * Utility function for parsing lists of URLs.
+	 * 
+	 * @param locationsStr
+	 *            Comma-separated list of URLs
+	 * @throws MalformedURLException
+	 */
+	protected static List<URL> parseLocations(String locationsStr) throws MalformedURLException {
+		StringTokenizer tok = new StringTokenizer(locationsStr, ",");
+		List<URL> urls = new ArrayList<URL>(tok.countTokens());
+		while (tok.hasMoreTokens()) {
+			String urlStr = tok.nextToken().trim();
+			urls.add(new URL(urlStr));
+		}
+		return urls;
+	}
+
+	public Set<OBRResolutionMode> getSupportedModes() {
+		return supportedModes;
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/Capability.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Capability.java
new file mode 100644
index 0000000..983afed
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Capability.java
@@ -0,0 +1,93 @@
+package aQute.lib.deployer.obr;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+public class Capability {
+	
+	private final String name;
+	private final List<Property> properties;
+
+	private Capability(String name, List<Property> properties) {
+		this.name = name;
+		this.properties = properties;
+	}
+	
+	public static class Builder {
+		private String name;
+		private final List<Property> properties = new LinkedList<Property>();
+		
+		public Builder setName(String name) {
+			this.name = name;
+			return this;
+		}
+		
+		public Builder addProperty(Property property) {
+			this.properties.add(property);
+			return this;
+		}
+		
+		public Capability build() {
+			if (name == null) throw new IllegalStateException("'name' field is not initialised.");
+			return new Capability(name, Collections.unmodifiableList(properties));
+		}
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public List<Property> getProperties() {
+		return properties;
+	}
+	
+	public Property findProperty(String propertyName) {
+		assert propertyName != null;
+		for (Property prop : properties) {
+			if (propertyName.equals(prop.getName()))
+				return prop;
+		}
+		return null;
+	}
+
+	@Override
+	public String toString() {
+		StringBuilder builder = new StringBuilder();
+		builder.append("Capability [name=").append(name).append(", properties=").append(properties).append("]");
+		return builder.toString();
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((name == null) ? 0 : name.hashCode());
+		result = prime * result
+				+ ((properties == null) ? 0 : properties.hashCode());
+		return result;
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj)
+			return true;
+		if (obj == null)
+			return false;
+		if (getClass() != obj.getClass())
+			return false;
+		Capability other = (Capability) obj;
+		if (name == null) {
+			if (other.name != null)
+				return false;
+		} else if (!name.equals(other.name))
+			return false;
+		if (properties == null) {
+			if (other.properties != null)
+				return false;
+		} else if (!properties.equals(other.properties))
+			return false;
+		return true;
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/CapabilityType.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/CapabilityType.java
new file mode 100644
index 0000000..d3f8ae3
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/CapabilityType.java
@@ -0,0 +1,31 @@
+package aQute.lib.deployer.obr;
+
+public enum CapabilityType {
+	
+	PACKAGE("package"),
+	EE("ee"),
+	BUNDLE("bundle"),
+	MODE("mode"),
+	OTHER(null);
+	
+	private String typeName;
+
+	CapabilityType(String name) {
+		this.typeName = name;
+	}
+	
+	public String getTypeName() {
+		return typeName;
+	}
+	
+	/**
+	 * @throws IllegalArgumentException
+	 */
+	public static CapabilityType getForTypeName(String typeName) {
+		for (CapabilityType type : CapabilityType.values()) {
+			if (type.typeName != null && type.typeName.equals(typeName))
+				return type;
+		}
+		throw new IllegalArgumentException("Unknown capability type: " + typeName);
+	}
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/IResourceListener.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/IResourceListener.java
new file mode 100644
index 0000000..22dae7a
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/IResourceListener.java
@@ -0,0 +1,15 @@
+package aQute.lib.deployer.obr;
+
+public interface IResourceListener {
+	/**
+	 * Process an OBR resource descriptor from the index document, and possibly
+	 * request early termination of the parser.
+	 * 
+	 * @param resource
+	 *            The resource descriptor to be processed.
+	 * @return Whether to continue parsing the document; returning {@code false}
+	 *         will result in the parser being stopped with a
+	 *         {@link StopParseException}.
+	 */
+	boolean processResource(Resource resource);
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/LocalOBR.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/LocalOBR.java
new file mode 100644
index 0000000..e59266a
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/LocalOBR.java
@@ -0,0 +1,198 @@
+package aQute.lib.deployer.obr;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.xml.transform.stream.StreamResult;
+
+import org.osgi.service.bindex.BundleIndexer;
+import org.xml.sax.InputSource;
+import org.xml.sax.XMLReader;
+
+import aQute.bnd.service.Refreshable;
+import aQute.bnd.service.Registry;
+import aQute.bnd.service.RegistryPlugin;
+import aQute.lib.deployer.FileRepo;
+import aQute.lib.io.IO;
+import aQute.lib.osgi.Jar;
+import aQute.libg.reporter.Reporter;
+import aQute.libg.sax.SAXUtil;
+import aQute.libg.sax.filters.MergeContentFilter;
+import aQute.libg.version.Version;
+
+public class LocalOBR extends OBR implements Refreshable, RegistryPlugin {
+	
+	public static final String PROP_LOCAL_DIR = "local";
+	public static final String PROP_READONLY = "readonly";
+
+	private final FileRepo storageRepo = new FileRepo();
+	
+	private Registry registry;
+	private File storageDir;
+	private File localIndex;
+	
+	private List<URL> indexUrls;
+	
+	public void setRegistry(Registry registry) {
+		this.registry = registry;
+	}
+	
+	@Override
+	public void setReporter(Reporter reporter) {
+		super.setReporter(reporter);
+		storageRepo.setReporter(reporter);
+	}
+
+	@Override
+	public void setProperties(Map<String, String> map) {
+		super.setProperties(map);
+		
+		// Load essential properties
+		String localDirPath = map.get(PROP_LOCAL_DIR);
+		if (localDirPath == null)
+			throw new IllegalArgumentException(String.format("Attribute '%s' must be set on LocalOBR plugin.", PROP_LOCAL_DIR));
+		storageDir = new File(localDirPath);
+		if (!storageDir.isDirectory())
+			throw new IllegalArgumentException(String.format("Local path '%s' does not exist or is not a directory.", localDirPath));
+		
+		// Configure the storage repository
+		Map<String, String> storageRepoConfig = new HashMap<String, String>(2);
+		storageRepoConfig.put(FileRepo.LOCATION, localDirPath);
+		storageRepoConfig.put(FileRepo.READONLY, map.get(PROP_READONLY));
+		storageRepo.setProperties(storageRepoConfig);
+		
+		// Set the local index and cache directory locations
+		localIndex = new File(storageDir, REPOSITORY_FILE_NAME);
+		if (localIndex.exists() && !localIndex.isFile())
+			throw new IllegalArgumentException(String.format("Cannot build local repository index: '%s' already exists but is not a plain file.", localIndex.getAbsolutePath()));
+		cacheDir = new File(storageDir, ".obrcache");
+		if (cacheDir.exists() && !cacheDir.isDirectory())
+			throw new IllegalArgumentException(String.format("Cannot create repository cache: '%s' already exists but is not directory.", cacheDir.getAbsolutePath()));
+	}
+	
+	@Override
+	protected void initialiseIndexes() throws Exception {
+		if (!localIndex.exists()) {
+			regenerateIndex();
+		}
+		try {
+			Collection<URL> remotes = super.getOBRIndexes();
+			indexUrls = new ArrayList<URL>(remotes.size() + 1);
+			indexUrls.add(localIndex.toURI().toURL());
+			indexUrls.addAll(remotes);
+		} catch (IOException e) {
+			throw new IllegalArgumentException("Error initialising local index URL", e);
+		}
+	}
+	
+	private void regenerateIndex() throws Exception {
+		BundleIndexer indexer = registry.getPlugin(BundleIndexer.class);
+		if (indexer == null)
+			throw new IllegalStateException("Cannot index repository: no Bundle Indexer service or plugin found.");
+		
+		Set<File> allFiles = new HashSet<File>();
+		gatherFiles(allFiles);
+		
+		FileOutputStream out = null;
+		try {
+			out = new FileOutputStream(localIndex);
+			if (!allFiles.isEmpty()) {
+				Map<String, String> config = new HashMap<String, String>();
+				config.put(BundleIndexer.REPOSITORY_NAME, this.getName());
+				config.put(BundleIndexer.ROOT_URL, localIndex.toURI().toURL().toString());
+				indexer.index(allFiles, out, config);
+			} else {
+				ByteArrayInputStream emptyRepo = new ByteArrayInputStream("<?xml version='1.0' encoding='UTF-8'?>\n<repository lastmodified='0'/>".getBytes());
+				IO.copy(emptyRepo, out);
+			}
+		} finally {
+			out.close();
+		}
+	}
+
+	private void gatherFiles(Set<File> allFiles) throws Exception {
+		List<String> bsns = storageRepo.list(null);
+		if (bsns != null) for (String bsn : bsns) {
+			List<Version> versions = storageRepo.versions(bsn);
+			if (versions != null) for (Version version : versions) {
+				File file = storageRepo.get(bsn, version.toString(), Strategy.HIGHEST, null);
+				if (file != null)
+					allFiles.add(file);
+			}
+		}
+	}
+
+	@Override
+	public List<URL> getOBRIndexes() {
+		return indexUrls;
+	}
+	
+	@Override
+	public boolean canWrite() {
+		return storageRepo.canWrite();
+	}
+	
+	@Override
+	public synchronized File put(Jar jar) throws Exception {
+		File newFile = storageRepo.put(jar);
+		
+		// Index the new file
+		BundleIndexer indexer = registry.getPlugin(BundleIndexer.class);
+		if (indexer == null)
+			throw new IllegalStateException("Cannot index repository: no Bundle Indexer service or plugin found.");
+		ByteArrayOutputStream newIndexBuffer = new ByteArrayOutputStream();
+		indexer.index(Collections.singleton(newFile), newIndexBuffer, null);
+		
+		// Merge into main index
+		File tempIndex = File.createTempFile("repository", ".xml");
+		FileOutputStream tempIndexOutput = new FileOutputStream(tempIndex);
+		MergeContentFilter merger = new MergeContentFilter();
+		XMLReader reader = SAXUtil.buildPipeline(new StreamResult(tempIndexOutput), new UniqueResourceFilter(), merger);
+		
+		try {
+			// Parse the newly generated index
+			reader.parse(new InputSource(new ByteArrayInputStream(newIndexBuffer.toByteArray())));
+			
+			// Parse the existing index (which may be empty/missing)
+			try {
+				reader.parse(new InputSource(new FileInputStream(localIndex)));
+			} catch (Exception e) {
+				reporter.warning("Existing local index is invalid or missing, overwriting (%s).", localIndex.getAbsolutePath());
+			}
+			
+			merger.closeRootAndDocument();
+		} finally {
+			tempIndexOutput.flush();
+			tempIndexOutput.close();
+		}
+		IO.copy(tempIndex, localIndex);
+		
+		// Re-read the index
+		reset();
+		init();
+		
+		return newFile;
+	}
+
+	public boolean refresh() {
+		reset();
+		return true;
+	}
+
+	public File getRoot() {
+		return storageDir;
+	}
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/OBR.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/OBR.java
new file mode 100644
index 0000000..ddf5fcc
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/OBR.java
@@ -0,0 +1,120 @@
+package aQute.lib.deployer.obr;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import aQute.lib.deployer.FileRepo;
+
+/**
+ * A simple read-only OBR-based repository that uses a list of index locations
+ * and a basic local cache.
+ * 
+ * <p>
+ * <h2>Properties</h2>
+ * <ul>
+ * <li><b>locations:</b> comma-separated list of index URLs. <b>NB:</b> surround with single quotes!</li>
+ * <li><b>name:</b> repository name; defaults to the index URLs.
+ * <li><b>cache:</b> local cache directory. May be omitted, in which case the repository will only be
+ * able to serve resources with {@code file:} URLs.</li>
+ * <li><b>location:</b> (deprecated) alias for "locations".
+ * </ul>
+ * 
+ * <p>
+ * <h2>Example</h2>
+ * 
+ * <pre>
+ * -plugin: aQute.lib.deployer.obr.OBR;locations='http://www.example.com/repository.xml';cache=${workspace}/.cache
+ * </pre>
+ * 
+ * @author Neil Bartlett
+ *
+ */
+public class OBR extends AbstractBaseOBR {
+	
+	public static final String PROP_LOCATIONS = "locations";
+	@Deprecated
+	public static final String PROP_LOCATION = "location";
+	public static final String PROP_CACHE = "cache";
+
+	protected List<URL> locations;
+	protected File cacheDir;
+
+	public void setProperties(Map<String, String> map) {
+		super.setProperties(map);
+		
+		String locationsStr = map.get(PROP_LOCATIONS);
+		// backwards compatibility
+		if (locationsStr == null) locationsStr = map.get(PROP_LOCATION);
+		
+		try {
+			if (locationsStr != null)
+				locations = parseLocations(locationsStr);
+			else
+				locations = Collections.emptyList();
+		} catch (MalformedURLException e) {
+			throw new IllegalArgumentException(String.format("Invalid location, unable to parse as URL list: %s", locationsStr), e);
+		}
+		
+		String cacheDirStr = map.get(PROP_CACHE);
+		if (cacheDirStr != null)
+			cacheDir = new File(cacheDirStr);
+	}
+	
+	private FileRepo lookupCachedFileRepo() {
+		if (registry != null) {
+			List<FileRepo> repos = registry.getPlugins(FileRepo.class);
+			for (FileRepo repo : repos) {
+				if ("cache".equals(repo.getName()))
+					return repo;
+			}
+		}
+		return null;
+	}
+
+	public List<URL> getOBRIndexes() {
+		return locations;
+	}
+
+	@Override
+	public synchronized File getCacheDirectory() {
+		if (cacheDir == null) {
+			FileRepo cacheRepo = lookupCachedFileRepo();
+			if (cacheRepo != null) {
+				File temp = new File(cacheRepo.getRoot(), ".obr");
+				temp.mkdirs();
+				if (temp.exists())
+					cacheDir = temp;
+			}
+		}
+		return cacheDir;
+	}
+	
+	public void setCacheDirectory(File cacheDir) {
+		this.cacheDir = cacheDir;
+	}
+	
+	@Override
+	public String getName() {
+		if (name != null && name != this.getClass().getName())
+			return name;
+		
+		StringBuilder builder = new StringBuilder();
+		
+		int count = 0;
+		for (URL location : locations) {
+			if (count++ > 0 ) builder.append(',');
+			builder.append(location);
+		}
+		return builder.toString();
+	}
+
+	public void setLocations(URL[] urls) {
+		this.locations = Arrays.asList(urls);
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/OBRSAXHandler.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/OBRSAXHandler.java
new file mode 100644
index 0000000..3634cb9
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/OBRSAXHandler.java
@@ -0,0 +1,79 @@
+package aQute.lib.deployer.obr;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+public class OBRSAXHandler extends DefaultHandler {
+	
+	private static final String TAG_RESOURCE = "resource";
+	private static final String ATTR_RESOURCE_ID = "id";
+	private static final String ATTR_RESOURCE_PRESENTATION_NAME = "presentationname";
+	private static final String ATTR_RESOURCE_SYMBOLIC_NAME = "symbolicname";
+	private static final String ATTR_RESOURCE_URI = "uri";
+	private static final String ATTR_RESOURCE_VERSION = "version";
+	
+	private static final String TAG_CAPABILITY = "capability";
+	private static final String ATTR_CAPABILITY_NAME = "name";
+	
+	private static final String TAG_REQUIRE = "require";
+	private static final String ATTR_REQUIRE_NAME = "name";
+	private static final String ATTR_REQUIRE_FILTER = "filter";
+	private static final String ATTR_REQUIRE_OPTIONAL = "optional";
+	
+	private static final String TAG_PROPERTY = "p";
+	private static final String ATTR_PROPERTY_NAME = "n";
+	private static final String ATTR_PROPERTY_TYPE = "t";
+	private static final String ATTR_PROPERTY_VALUE = "v";
+
+	private final String baseUrl;
+	private final IResourceListener resourceListener;
+	
+	private Resource.Builder resourceBuilder = null;
+	private Capability.Builder capabilityBuilder = null;
+	private Require require = null;
+
+	public OBRSAXHandler(String baseUrl, IResourceListener listener) {
+		this.baseUrl = baseUrl;
+		this.resourceListener = listener;
+	}
+	
+
+	@Override
+	public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException {
+		if (TAG_RESOURCE.equals(qName)) {
+			resourceBuilder = new Resource.Builder()
+				.setId(atts.getValue(ATTR_RESOURCE_ID))
+				.setPresentationName(atts.getValue(ATTR_RESOURCE_PRESENTATION_NAME))
+				.setSymbolicName(atts.getValue(ATTR_RESOURCE_SYMBOLIC_NAME))
+				.setUrl(atts.getValue(ATTR_RESOURCE_URI))
+				.setVersion(atts.getValue(ATTR_RESOURCE_VERSION))
+				.setBaseUrl(baseUrl);
+		} else if (TAG_CAPABILITY.equals(qName)) {
+			capabilityBuilder = new Capability.Builder()
+				.setName(atts.getValue(ATTR_CAPABILITY_NAME));
+		} else if (TAG_REQUIRE.equals(qName)) {
+			require = new Require(atts.getValue(ATTR_REQUIRE_NAME), atts.getValue(ATTR_REQUIRE_FILTER), "true".equalsIgnoreCase(atts.getValue(ATTR_REQUIRE_OPTIONAL)));
+		} else if (TAG_PROPERTY.equals(qName)) {
+			Property p = new Property(atts.getValue(ATTR_PROPERTY_NAME), atts.getValue(ATTR_PROPERTY_TYPE), atts.getValue(ATTR_PROPERTY_VALUE));
+			if (capabilityBuilder != null)
+				capabilityBuilder.addProperty(p);
+		}
+	}
+
+	@Override
+	public void endElement(String uri, String localName, String qName) throws SAXException {
+		if (TAG_CAPABILITY.equals(qName)) {
+			resourceBuilder.addCapability(capabilityBuilder);
+			capabilityBuilder = null;
+		} else if (TAG_RESOURCE.equals(qName)) {
+			if (!resourceListener.processResource(resourceBuilder.build()))
+				throw new StopParseException();
+			resourceBuilder = null;
+		} else if (TAG_REQUIRE.equals(qName)) {
+			resourceBuilder.addRequire(require);
+			require = null;
+		}
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/Property.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Property.java
new file mode 100644
index 0000000..1eaaa8a
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Property.java
@@ -0,0 +1,79 @@
+package aQute.lib.deployer.obr;
+
+/**
+ * @immutable
+ * @author Neil Bartlett
+ */
+public class Property {
+	
+	static final String PACKAGE = "package";
+	static final String USES = "uses";
+	static final String VERSION = "version";
+
+	private final String name;
+	private final String type;
+	private final String value;
+
+	public Property(String name, String type, String value) {
+		this.name = name;
+		this.type = type;
+		this.value = value;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public String getType() {
+		return type;
+	}
+
+	public String getValue() {
+		return value;
+	}
+
+	@Override
+	public String toString() {
+		StringBuilder builder = new StringBuilder();
+		builder.append("Property [name=").append(name).append(", type=").append(type).append(", value=").append(value).append("]");
+		return builder.toString();
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((name == null) ? 0 : name.hashCode());
+		result = prime * result + ((type == null) ? 0 : type.hashCode());
+		result = prime * result + ((value == null) ? 0 : value.hashCode());
+		return result;
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj)
+			return true;
+		if (obj == null)
+			return false;
+		if (getClass() != obj.getClass())
+			return false;
+		Property other = (Property) obj;
+		if (name == null) {
+			if (other.name != null)
+				return false;
+		} else if (!name.equals(other.name))
+			return false;
+		if (type == null) {
+			if (other.type != null)
+				return false;
+		} else if (!type.equals(other.type))
+			return false;
+		if (value == null) {
+			if (other.value != null)
+				return false;
+		} else if (!value.equals(other.value))
+			return false;
+		return true;
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/Require.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Require.java
new file mode 100644
index 0000000..d8d0fbb
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Require.java
@@ -0,0 +1,73 @@
+package aQute.lib.deployer.obr;
+
+public class Require {
+
+	private final String name;
+	private final String filter;
+	private final boolean optional;
+
+	public Require(String name, String filter, boolean optional) {
+		this.name = name;
+		this.filter = filter;
+		this.optional = optional;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public String getFilter() {
+		return filter;
+	}
+
+	public boolean isOptional() {
+		return optional;
+	}
+
+	@Override
+	public String toString() {
+		StringBuilder builder = new StringBuilder();
+		builder.append("Require [");
+		if (name != null)
+			builder.append("name=").append(name).append(", ");
+		if (filter != null)
+			builder.append("filter=").append(filter).append(", ");
+		builder.append("optional=").append(optional).append("]");
+		return builder.toString();
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((filter == null) ? 0 : filter.hashCode());
+		result = prime * result + ((name == null) ? 0 : name.hashCode());
+		result = prime * result + (optional ? 1231 : 1237);
+		return result;
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj)
+			return true;
+		if (obj == null)
+			return false;
+		if (getClass() != obj.getClass())
+			return false;
+		Require other = (Require) obj;
+		if (filter == null) {
+			if (other.filter != null)
+				return false;
+		} else if (!filter.equals(other.filter))
+			return false;
+		if (name == null) {
+			if (other.name != null)
+				return false;
+		} else if (!name.equals(other.name))
+			return false;
+		if (optional != other.optional)
+			return false;
+		return true;
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/Resource.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Resource.java
new file mode 100644
index 0000000..a355326
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/Resource.java
@@ -0,0 +1,240 @@
+package aQute.lib.deployer.obr;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * @immutable
+ * @author Neil Bartlett
+ */
+public class Resource {
+	
+	private final String id;
+	private final String presentationName;
+	private final String symbolicName;
+	private final String baseUrl;
+	private final String url;
+	private final String version;
+	private final List<Capability> capabilities;
+	private final List<Require> requires;
+
+	private Resource(String id, String presentationName, String symbolicName, String baseUrl, String url, String version, List<Capability> capabilities, List<Require> requires) {
+		this.id = id;
+		this.presentationName = presentationName;
+		this.symbolicName = symbolicName;
+		this.baseUrl = baseUrl;
+		this.url = url;
+		this.version = version;
+		
+		this.capabilities = capabilities;
+		this.requires = requires;
+	}
+	
+	public static class Builder {
+		private String id;
+		private String presentationName;
+		private String symbolicName;
+		private String baseUrl;
+		private String url;
+		private String version;
+		private final List<Capability> capabilities = new LinkedList<Capability>();
+		private final List<Require> requires = new LinkedList<Require>();
+		
+		public Builder setId(String id) {
+			this.id = id;
+			return this;
+		}
+		public Builder setPresentationName(String presentationName) {
+			this.presentationName = presentationName;
+			return this;
+		}
+		public Builder setSymbolicName(String symbolicName) {
+			this.symbolicName = symbolicName;
+			return this;
+		}
+		public Builder setBaseUrl(String baseUrl) {
+			this.baseUrl = baseUrl;
+			return this;
+		}
+		public Builder setUrl(String url) {
+			this.url = url;
+			return this;
+		}
+		public Builder setVersion(String version) {
+			this.version = version;
+			return this;
+		}
+		public Builder addCapability(Capability capability) {
+			this.capabilities.add(capability);
+			return this;
+		}
+		public Builder addCapability(Capability.Builder capabilityBuilder) {
+			this.capabilities.add(capabilityBuilder.build());
+			return this;
+		}
+		public Builder addRequire(Require require) {
+			this.requires.add(require);
+			return this;
+		}
+		
+		public Resource build() {
+			if (id == null) throw new IllegalStateException("'id' field is not initialised");
+			if (symbolicName == null) throw new IllegalStateException("'symbolicName' field is not initialised");
+			if (url == null) throw new IllegalStateException("'url' field is not initialised");
+			
+			return new Resource(id, presentationName, symbolicName, baseUrl, url, version, Collections.unmodifiableList(capabilities), Collections.unmodifiableList(requires));
+		}
+	}
+
+	public String getId() {
+		return id;
+	}
+
+	public String getPresentationName() {
+		return presentationName;
+	}
+
+	public String getSymbolicName() {
+		return symbolicName;
+	}
+	
+	public String getBaseUrl() {
+		return baseUrl;
+	}
+
+	public String getUrl() {
+		return url;
+	}
+
+	public String getVersion() {
+		return version;
+	}
+
+	public List<Capability> getCapabilities() {
+		return capabilities;
+	}
+	
+	public Capability findPackageCapability(String pkgName) {
+		for (Capability capability : capabilities) {
+			if (CapabilityType.PACKAGE.getTypeName().equals(capability.getName())) {
+				List<Property> props = capability.getProperties();
+				for (Property prop : props) {
+					if (Property.PACKAGE.equals(prop.getName())) {
+						if (pkgName.equals(prop.getValue()))
+							return capability;
+						else
+							break;
+					}
+				}
+			}
+		}
+		return null;
+	}
+
+
+	
+	public List<Require> getRequires() {
+		return requires;
+	}
+	
+	public Require findRequire(String name) {
+		for (Require require : requires) {
+			if (name.equals(require.getName()))
+				return require;
+		}
+		return null;
+	}
+	
+	public Require findPackageRequire(String usesPkgName) {
+		String matchString = String.format("(package=%s)", usesPkgName);
+		
+		for (Require require : requires) {
+			if (CapabilityType.PACKAGE.getTypeName().equals(require.getName())) {
+				String filter = require.getFilter();
+				if (filter.indexOf(matchString) > -1)
+					return require;
+			}
+		}
+		return null;
+	}
+
+	@Override
+	public String toString() {
+		StringBuilder builder = new StringBuilder();
+		builder.append("Resource [id=").append(id)
+				.append(", presentationName=").append(presentationName)
+				.append(", symbolicName=").append(symbolicName)
+				.append(", baseUrl=").append(baseUrl)
+				.append(", url=").append(url).append(", version=")
+				.append(version).append(", capabilities=").append(capabilities)
+				.append("]");
+		return builder.toString();
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((baseUrl == null) ? 0 : baseUrl.hashCode());
+		result = prime * result
+				+ ((capabilities == null) ? 0 : capabilities.hashCode());
+		result = prime * result + ((id == null) ? 0 : id.hashCode());
+		result = prime
+				* result
+				+ ((presentationName == null) ? 0 : presentationName.hashCode());
+		result = prime * result
+				+ ((symbolicName == null) ? 0 : symbolicName.hashCode());
+		result = prime * result + ((url == null) ? 0 : url.hashCode());
+		result = prime * result + ((version == null) ? 0 : version.hashCode());
+		return result;
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj)
+			return true;
+		if (obj == null)
+			return false;
+		if (getClass() != obj.getClass())
+			return false;
+		Resource other = (Resource) obj;
+		if (baseUrl == null) {
+			if (other.baseUrl != null)
+				return false;
+		} else if (!baseUrl.equals(other.baseUrl))
+			return false;
+		if (capabilities == null) {
+			if (other.capabilities != null)
+				return false;
+		} else if (!capabilities.equals(other.capabilities))
+			return false;
+		if (id == null) {
+			if (other.id != null)
+				return false;
+		} else if (!id.equals(other.id))
+			return false;
+		if (presentationName == null) {
+			if (other.presentationName != null)
+				return false;
+		} else if (!presentationName.equals(other.presentationName))
+			return false;
+		if (symbolicName == null) {
+			if (other.symbolicName != null)
+				return false;
+		} else if (!symbolicName.equals(other.symbolicName))
+			return false;
+		if (url == null) {
+			if (other.url != null)
+				return false;
+		} else if (!url.equals(other.url))
+			return false;
+		if (version == null) {
+			if (other.version != null)
+				return false;
+		} else if (!version.equals(other.version))
+			return false;
+		return true;
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/StopParseException.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/StopParseException.java
new file mode 100644
index 0000000..2ca3005
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/StopParseException.java
@@ -0,0 +1,7 @@
+package aQute.lib.deployer.obr;
+
+import org.xml.sax.SAXException;
+
+public class StopParseException extends SAXException {
+	private static final long serialVersionUID = 1L;
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/URLResourceHandle.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/URLResourceHandle.java
new file mode 100644
index 0000000..75b9243
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/URLResourceHandle.java
@@ -0,0 +1,136 @@
+package aQute.lib.deployer.obr;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URL;
+import java.net.URLEncoder;
+
+import aQute.bnd.service.ResourceHandle;
+
+public class URLResourceHandle implements ResourceHandle {
+	
+	static final String FILE_SCHEME = "file:";
+	static final String HTTP_SCHEME = "http:";
+	
+	final File cacheDir;
+	
+	// The resolved, absolute URL of the resource
+	final URL url;
+	
+	// The local file, if the resource IS a file, otherwise null.
+	final File localFile;
+	
+	// The cached file copy of the resource, if it is remote and has been downloaded.
+	final File cachedFile;
+
+	public URLResourceHandle(String url, String baseUrl, final File cacheDir) throws IOException {
+		this.cacheDir = cacheDir;
+		if (url.startsWith(FILE_SCHEME)) {
+			// File URL may be relative or absolute
+			File file = new File(url.substring(FILE_SCHEME.length()));
+			if (file.isAbsolute()) {
+				this.localFile = file;
+			} else {
+				if (!baseUrl.startsWith(FILE_SCHEME))
+					throw new IllegalArgumentException("Relative file URLs cannot be resolved if the base URL is a non-file URL.");
+				this.localFile = resolveFile(baseUrl.substring(FILE_SCHEME.length()), file.toString());
+			}
+			this.url = localFile.toURI().toURL();
+			if (!localFile.isFile() && !localFile.isDirectory())
+				throw new FileNotFoundException("File URL " + this.url + " points at a non-existing file.");
+			this.cachedFile = null;
+		} else if (url.startsWith(HTTP_SCHEME)) {
+			// HTTP URLs must be absolute
+			this.url = new URL(url);
+			this.localFile = null;
+			this.cachedFile = mapRemoteURL(this.url);
+		} else {
+			// A path with no scheme means resolve relative to the base URL
+			if (baseUrl.startsWith(FILE_SCHEME)) {
+				this.localFile = resolveFile(baseUrl.substring(FILE_SCHEME.length()), url);
+				this.url = localFile.toURI().toURL();
+				this.cachedFile = null;
+			} else {
+				URL base = new URL(baseUrl);
+				this.url = new URL(base, url);
+				this.localFile = null;
+				this.cachedFile = mapRemoteURL(this.url);
+			}
+		}
+	}
+	
+	File resolveFile(String baseFileName, String fileName) {
+		File resolved;
+		
+		File baseFile = new File(baseFileName);
+		if (baseFile.isDirectory())
+			resolved = new File(baseFile, fileName);
+		else if (baseFile.isFile())
+			resolved = new File(baseFile.getParentFile(), fileName);
+		else
+			throw new IllegalArgumentException("Cannot resolve relative to non-existant base file path: " + baseFileName);
+		
+		return resolved;
+	}
+
+	private File mapRemoteURL(URL url) throws UnsupportedEncodingException {
+		String encoded = URLEncoder.encode(url.toString(), "UTF-8");
+		return new File(cacheDir, encoded);
+	}
+
+	public String getName() {
+		return url.toString();
+	}
+
+	public Location getLocation() {
+		Location result;
+		
+		if (localFile != null)
+			result = Location.local;
+		else if (cachedFile.exists())
+			result = Location.remote_cached;
+		else
+			result = Location.remote;
+		
+		return result;
+	}
+
+	public File request() throws IOException {
+		if (localFile != null)
+			return localFile;
+		if (cachedFile == null)
+			throw new IllegalStateException("Invalid URLResourceHandle: both local file and cache file are uninitialised.");
+		
+		if (!cachedFile.exists()) {
+			cacheDir.mkdirs();
+			downloadToFile(url, cachedFile);
+		}
+		
+		return cachedFile;
+	}
+	
+	private static void downloadToFile(URL url, File file) throws IOException {
+		InputStream in = null;
+		OutputStream out = null;
+		try {
+			in = url.openStream();
+			out = new FileOutputStream(file);
+			
+			byte[] buf = new byte[1024];
+			for(;;) {
+				int bytes = in.read(buf, 0, 1024);
+				if (bytes < 0) break;
+				out.write(buf, 0, bytes);
+			}
+		} finally {
+			try { if (in != null) in.close(); } catch (IOException e) {};
+			try { if (out != null) in.close(); } catch (IOException e) {};
+		}
+	}
+
+}
diff --git a/bundleplugin/src/main/java/aQute/lib/deployer/obr/UniqueResourceFilter.java b/bundleplugin/src/main/java/aQute/lib/deployer/obr/UniqueResourceFilter.java
new file mode 100644
index 0000000..0ad2cfb
--- /dev/null
+++ b/bundleplugin/src/main/java/aQute/lib/deployer/obr/UniqueResourceFilter.java
@@ -0,0 +1,54 @@
+package aQute.lib.deployer.obr;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.xml.sax.Attributes;
+
+import aQute.libg.sax.filters.ElementSelectionFilter;
+
+public class UniqueResourceFilter extends ElementSelectionFilter {
+	
+	final Set<String> uris = new HashSet<String>();
+	final Map<String, List<String>> filteredResources = new HashMap<String, List<String>>();
+
+	@Override
+	protected boolean select(int depth, String uri, String localName, String qName, Attributes attribs) {
+		if ("resource".equals(qName)) {
+			String resourceUri = attribs.getValue("uri");
+			if (uris.contains(resourceUri)) {
+				addFilteredBundle(attribs.getValue("symbolicname"), attribs.getValue("version"));
+				return false;
+			}
+			uris.add(resourceUri);
+		}
+		return true;
+	}
+
+	private void addFilteredBundle(String bsn, String version) {
+		List<String> versions = filteredResources.get(bsn);
+		if (versions == null) {
+			versions = new LinkedList<String>();
+			filteredResources.put(bsn, versions);
+		}
+		versions.add(version);
+	}
+	
+	public Collection<String> getFilteredBSNs() {
+		return filteredResources.keySet();
+	}
+	
+	public Collection<String> getFilteredVersions(String bsn) {
+		List<String> list = filteredResources.get(bsn);
+		if (list == null)
+			list = Collections.emptyList();
+		return list;
+	}
+
+}