/* * ModeShape (http://www.modeshape.org) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.modeshape.web.jcr.webdav; import java.io.IOException; import java.io.InputStream; import java.security.Principal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; import javax.jcr.Item; import javax.jcr.NoSuchWorkspaceException; import javax.jcr.Node; import javax.jcr.NodeIterator; import javax.jcr.PathNotFoundException; import javax.jcr.Property; import javax.jcr.PropertyIterator; import javax.jcr.RepositoryException; import javax.jcr.Session; import javax.servlet.http.HttpServletRequest; import org.modeshape.common.i18n.I18n; import org.modeshape.common.logging.Logger; import org.modeshape.common.util.CheckArg; import org.modeshape.common.util.IoUtil; import org.modeshape.common.util.StringUtil; import org.modeshape.jcr.api.NamespaceRegistry; import org.modeshape.web.jcr.RepositoryManager; import org.modeshape.webdav.ITransaction; import org.modeshape.webdav.IWebdavStore; import org.modeshape.webdav.StoredObject; import org.modeshape.webdav.exceptions.ObjectNotFoundException; import org.modeshape.webdav.exceptions.WebdavException; /** * Implementation of the {@code IWebdavStore} interface that uses a JCR repository as a backing store. * <p> * This implementation takes several OSX-specific WebDAV workarounds from the WebDAVImpl class in Drools Guvnor. * </p> */ public class ModeShapeWebdavStore implements IWebdavStore { /** * OS X attempts to create ".DS_Store" files to store a folder's icon positions and background image. We choose not to store * these in our implementation, so we ignore requests to create them. */ private static final String DS_STORE_SUFFIX = ".DS_Store"; private static final ThreadLocal<HttpServletRequest> THREAD_LOCAL_REQUEST = new ThreadLocal<HttpServletRequest>(); /** OSX workaround */ private static final Map<String, byte[]> OSX_DOUBLE_DATA = Collections.synchronizedMap(new WeakHashMap<String, byte[]>()); private static final String CREATED_PROP_NAME = "jcr:created"; /** * List of namespace prefixes that should not be returned in the XML response as a) they cannot appear in the actual elements * and b) there are certain clients which can misbehave if they see them in the response (e.g. the Windows Client and the XML * prefix) */ protected static final Set<String> EXCLUDED_NAMESPACE_PREFIXES = org.modeshape.common.collection.Collections.unmodifiableSet(NamespaceRegistry.PREFIX_XML, "xs", "xsi", "xmlns"); private final RequestResolver requestResolver; private final ContentMapper contentMapper; private final Logger logger = Logger.getLogger(getClass()); /** * Creates a new store instance * * @param requestResolver a {@link RequestResolver} instance, never null * @param contentMapper a {@link ContentMapper} instance, never null */ public ModeShapeWebdavStore( RequestResolver requestResolver, ContentMapper contentMapper ) { super(); this.requestResolver = requestResolver; this.contentMapper = contentMapper; } /** * Updates thread-local storage for the current thread to reference the given request. * * @param request the request to store in thread-local storage; null to clear the storage */ static void setRequest( HttpServletRequest request ) { THREAD_LOCAL_REQUEST.set(request); } @Override public ITransaction begin( Principal principal ) { return new JcrSessionTransaction(principal); } @Override public void commit( ITransaction transaction ) { CheckArg.isNotNull(transaction, "transaction"); assert transaction instanceof JcrSessionTransaction; ((JcrSessionTransaction)transaction).commit(); } @Override public void rollback( ITransaction transaction ) { // No op. By not saving the session, we will let the session expire without committing any changes } @Override public void checkAuthentication( ITransaction transaction ) { // No op. } @Override public void destroy() { } /** * @see IWebdavStore#createFolder(org.modeshape.webdav.ITransaction, String) */ @Override public void createFolder( ITransaction transaction, String folderUri ) { folderUri = removeTrailingSlash(folderUri); int ind = folderUri.lastIndexOf('/'); String parentUri = folderUri.substring(0, ind + 1); String resourceName = folderUri.substring(ind + 1); try { logger.debug("WebDAV create folder at: " + parentUri); ResolvedRequest resolvedParent = resolveRequest(parentUri); logger.debug("WebDAV create folder at: " + resolvedParent); if (resolvedParent.getPath() == null) { if (resolvedParent.getRepositoryName() == null) { // Can't create a repository ... throw new WebdavException(WebdavI18n.cannotCreateRepository.text(resourceName)); } if (resolvedParent.getWorkspaceName() != null) { // Really trying to create a node under the root ... resolvedParent = resolvedParent.withPath("/"); } else { // Can't create a workspace ... I18n msg = WebdavI18n.cannotCreateWorkspaceInRepository; throw new WebdavException(msg.text(resourceName, resolvedParent.getRepositoryName())); } } Node parentNode = nodeFor(transaction, resolvedParent); contentMapper.createFolder(parentNode, resourceName); } catch (RepositoryException re) { throw translate(re); } } @Override public void createResource( ITransaction transaction, String resourceUri ) { resourceUri = removeTrailingSlash(resourceUri); // Mac OS X workaround from Drools Guvnor if (resourceUri.endsWith(DS_STORE_SUFFIX)) { return; } int ind = resourceUri.lastIndexOf('/'); String parentUri = resourceUri.substring(0, ind + 1); String resourceName = resourceUri.substring(ind + 1); // Mac OS X workaround from Drools Guvnor if (resourceName.startsWith("._")) { OSX_DOUBLE_DATA.put(resourceUri, null); return; } try { ResolvedRequest resolvedParent = resolveRequest(parentUri); if (resolvedParent.getPath() == null) { if (resolvedParent.getRepositoryName() == null) { // Can't create a repository ... throw new WebdavException(WebdavI18n.cannotCreateRepository.text(resourceName)); } if (resolvedParent.getWorkspaceName() != null) { // Really trying to create a node under the root ... resolvedParent = resolvedParent.withPath("/"); } else { // Can't create a workspace ... I18n msg = WebdavI18n.cannotCreateWorkspaceInRepository; throw new WebdavException(msg.text(resourceName, resolvedParent.getRepositoryName())); } } Node parentNode = nodeFor(transaction, resolvedParent); contentMapper.createFile(parentNode, resourceName); } catch (RepositoryException re) { throw translate(re); } } private String removeTrailingSlash( String uri ) { if (!StringUtil.isBlank(uri) && uri.length() > 1 && uri.endsWith("/")) { return uri.substring(0, uri.length() - 1); } return uri; } @Override public String[] getChildrenNames( ITransaction transaction, String folderUri ) { try { logger.trace("WebDAV getChildrenNames(txn,\"" + folderUri + "\")"); ResolvedRequest resolved = resolveRequest(folderUri); logger.trace("WebDAV -> resolves to: " + resolved); if (resolved.getPath() == null) { // It does not resolve to the path of a node, so see if the repository/workspace exist ... return childrenFor(transaction, resolved); } Node node = nodeFor(transaction, resolved); // throws exception if not found logger.trace("WebDAV -> node: " + node); if (!isFolder(node)) { return null; // no children } List<String> children = namesOfChildren(node); logger.trace("WebDAV -> children: " + children); return children.toArray(new String[children.size()]); } catch (RepositoryException re) { throw translate(re); } } protected static List<String> namesOfChildren( Node node ) throws RepositoryException { List<String> children = new LinkedList<String>(); for (NodeIterator iter = node.getNodes(); iter.hasNext();) { Node child = iter.nextNode(); String name = child.getIndex() == 1 ? child.getName() : child.getName() + "[" + child.getIndex() + "]"; children.add(name); } return children; } @Override public InputStream getResourceContent( ITransaction transaction, String resourceUri ) { try { ResolvedRequest resolved = resolveRequest(resourceUri); if (resolved.getPath() == null) { // Not a node, so there's no content ... return null; } Node node = nodeFor(transaction, resolved); // throws exception if not found if (!isFile(node)) { return null; } return contentMapper.getResourceContent(node); } catch (IOException ioe) { throw new WebdavException(ioe); } catch (RepositoryException re) { throw translate(re); } } @Override public long getResourceLength( ITransaction transaction, String resourceUri ) { try { ResolvedRequest resolved = resolveRequest(resourceUri); if (resolved.getPath() == null) { // Not a node, so there's no length ... return -1; } Node node = nodeFor(transaction, resolved); // throws exception if not found return contentMapper.getResourceLength(node); } catch (IOException ioe) { throw new WebdavException(ioe); } catch (RepositoryException re) { throw translate(re); } } @Override public StoredObject getStoredObject( ITransaction transaction, String uri ) { if (uri.length() == 0) { uri = "/"; } StoredObject ob = new StoredObject(); try { logger.trace("WebDAV getStoredObject at \"" + uri + "\""); ResolvedRequest resolved = resolveRequest(uri); logger.debug("WebDAV getStoredObject at \"" + uri + "\" resolved to \"" + resolved + "\""); String path = resolved.getPath(); if (path == null) { // It does not resolve to the path of a node, so see if the repository/workspace exist ... if (repositoryAndWorkspaceExist(transaction, resolved)) { ob.setFolder(true); Date now = new Date(); ob.setCreationDate(now); ob.setLastModified(now); ob.setResourceLength(0); return ob; } // It does not exist, so return null return null; } int ind = path.lastIndexOf('/'); String resourceName = path.substring(ind + 1); if (resourceName.startsWith("._")) { // OS-X uses these hidden files ... return null; } Node node = nodeFor(transaction, resolved); if (isFolder(node)) { ob.setFolder(true); Date createDate = null; if (node.hasProperty(CREATED_PROP_NAME)) { createDate = node.getProperty(CREATED_PROP_NAME).getDate().getTime(); } else { createDate = new Date(); } ob.setCreationDate(createDate); ob.setLastModified(new Date()); ob.setResourceLength(0); } else if (isFile(node)) { ob.setFolder(false); Date createDate = null; if (node.hasProperty(CREATED_PROP_NAME)) { createDate = node.getProperty(CREATED_PROP_NAME).getDate().getTime(); } else { createDate = new Date(); } ob.setCreationDate(createDate); ob.setLastModified(contentMapper.getLastModified(node)); ob.setResourceLength(contentMapper.getResourceLength(node)); } else { ob.setNullResource(true); } } catch (PathNotFoundException pnfe) { return null; } catch (IOException ioe) { throw new WebdavException(ioe); } catch (RepositoryException re) { throw translate(re); } return ob; } @Override public void removeObject( ITransaction transaction, String uri ) { // Mac OS X workaround from Drools Guvnor String resourceName = resourceNameFromResourcePath(uri); if (resourceName.startsWith("._")) { OSX_DOUBLE_DATA.put(uri, null); return; } try { ResolvedRequest resolved = resolveRequest(uri); if (resolved.getPath() != null) { // It does resolve to the path of a node, so try to find the node and remove it ... Node node = nodeFor(transaction, resolved); node.remove(); } // Otherwise just return silently } catch (PathNotFoundException pnfe) { // Return silently } catch (RepositoryException re) { throw translate(re); } } protected String resourceNameFromResourcePath( String path ) { int ind = path.lastIndexOf('/'); return path.substring(ind + 1); } @Override public long setResourceContent( ITransaction transaction, String resourceUri, InputStream content, String contentType, String characterEncoding ) { // Mac OS X workaround from Drools Guvnor if (shouldIgnoreResource(resourceUri)) { return 0; } // Mac OS X workaround from Drools Guvnor String resourceName = resourceNameFromResourcePath(resourceUri); if (resourceName.startsWith("._")) { try { OSX_DOUBLE_DATA.put(resourceUri, IoUtil.readBytes(content)); } catch (IOException e) { throw new RuntimeException(e); } return 0; } try { ResolvedRequest resolved = resolveRequest(resourceUri); if (resolved.getPath() == null) { // The request does not resolve to a node return -1; } // It does resolve to the path of a node, though the node may not exist ... Node node = nodeFor(transaction, resolved); if (!isFile(node)) { return -1; } return contentMapper.setContent(node, resourceName, content, contentType, characterEncoding); } catch (RepositoryException re) { throw translate(re); } catch (IOException ioe) { throw new WebdavException(ioe); } } private boolean shouldIgnoreResource( String resourceUri ) { return resourceUri.endsWith(".DS_Store"); } @Override public Map<String, String> setCustomProperties( ITransaction transaction, String resourceUri, Map<String, Object> propertiesToSet, List<String> propertiesToRemove ) { resourceUri = removeTrailingSlash(resourceUri); if (shouldIgnoreResource(resourceUri)) { logger().debug("Resource {0} ignored.", resourceUri); return null; } try { ResolvedRequest resolvedRequest = resolveRequest(resourceUri); if (resolvedRequest.getPath() == null) { throw new ObjectNotFoundException("The resource at path " + resourceUri + " does not represent a valid JCR node"); } Node node = nodeFor(transaction, resolvedRequest); Map<String, String> response = new LinkedHashMap<String, String>(); // update properties for (String propertyName : propertiesToSet.keySet()) { String jcrPropertyName = jcrPropertyName(transaction, resolvedRequest, propertyName); Object value = propertiesToSet.get(propertyName); if (value instanceof Collection) { Collection<?> collection = (Collection<?>)value; String[] jcrValue = new String[collection.size()]; int i = 0; for (Object collectionObject : collection) { jcrValue[i++] = collectionObject.toString(); } try { node.setProperty(jcrPropertyName, jcrValue); } catch (RepositoryException e) { response.put(propertyName, e.getMessage()); markForRollback(transaction, resolvedRequest); return response; } } else { try { node.setProperty(jcrPropertyName, value.toString()); } catch (RepositoryException e) { response.put(propertyName, e.getMessage()); markForRollback(transaction, resolvedRequest); return response; } } } // remove properties for (String propertyName : propertiesToRemove) { String jcrPropertyName = jcrPropertyName(transaction, resolvedRequest, propertyName); try { node.getProperty(jcrPropertyName).remove(); } catch (RepositoryException e) { response.put(propertyName, e.getMessage()); markForRollback(transaction, resolvedRequest); return response; } } return response; } catch (RepositoryException e) { throw translate(e); } } private String jcrPropertyName( ITransaction transaction, ResolvedRequest resolvedRequest, String webdavPropertyName ) throws RepositoryException { String[] parts = webdavPropertyName.split("\\:"); if (parts.length == 0) { return webdavPropertyName; } List<String> parsedParts = new ArrayList<String>(); for (String part : parts) { if (!StringUtil.isBlank(part) && !part.equalsIgnoreCase(":")) { parsedParts.add(part); } } if (parsedParts.isEmpty()) { return webdavPropertyName; } // use the last part as the local name of the jcr property String localName = parsedParts.remove(parsedParts.size() - 1); // try to take each part from the webdav property name to see if it matches a session prefix (e.g. jcr:) Session session = sessionFor(transaction, resolvedRequest); List<String> namespacePrefixes = Arrays.asList(session.getNamespacePrefixes()); for (int i = parsedParts.size() - 1; i >= 0; i--) { String prefix = parsedParts.get(i); if (namespacePrefixes.contains(prefix)) { return prefix + ":" + localName; } } // we don't have a jcr-recognized prefix, so we'll just send the plain property return localName; } @Override public Map<String, Object> getCustomProperties( ITransaction transaction, String resourceUri ) { resourceUri = removeTrailingSlash(resourceUri); if (shouldIgnoreResource(resourceUri)) { return Collections.emptyMap(); } try { ResolvedRequest resolvedRequest = resolveRequest(resourceUri); if (resolvedRequest.getPath() == null) { return Collections.emptyMap(); } Node node = nodeFor(transaction, resolvedRequest); Map<String, Object> response = new LinkedHashMap<String, Object>(); PropertyIterator propertyIterator = node.getProperties(); while (propertyIterator.hasNext()) { Property property = propertyIterator.nextProperty(); if (property.isMultiple()) { logger().debug(WebdavI18n.warnMultiValuedProperty.text(property.getPath())); continue; } response.put(property.getName(), property.getString()); } return response; } catch (RepositoryException e) { throw translate(e); } } @Override public Map<String, String> getCustomNamespaces( ITransaction transaction, String resourceUri ) { resourceUri = removeTrailingSlash(resourceUri); try { ResolvedRequest resolvedRequest = resolveRequest(resourceUri); if (resolvedRequest.getPath() == null) { return Collections.emptyMap(); } return ((JcrSessionTransaction)transaction).namespacesFor(resolvedRequest); } catch (RepositoryException e) { throw translate(e); } } /** * @param node the node to check; may not be null * @return true if {@code node} represents a file (as opposed to a folder or file content) * @throws RepositoryException if an error occurs checking the node's primary type */ private boolean isFile( Node node ) throws RepositoryException { return contentMapper.isFile(node); } /** * @param node the node to check; may not be null * @return true if {@code node} represents a folder (as opposed to a file or file content) * @throws RepositoryException if an error occurs checking the node's primary type */ private boolean isFolder( Node node ) throws RepositoryException { return contentMapper.isFolder(node); } /** * Resolve the URI into a repository name, workspace name, and node path. Note that some URIs might not resolve to a * repository (but no workspace or path), a workspace (but no path), or even a repository. * * @param uri the URI from the request * @return the resolved information; never null * @throws WebdavException if the URI is invalid or otherwise not acceptable */ private ResolvedRequest resolveRequest( String uri ) throws WebdavException { HttpServletRequest request = THREAD_LOCAL_REQUEST.get(); return requestResolver.resolve(request, uri); } /** * Get the node that corresponds to the resolved request, using the supplied active transaction. * * @param transaction the active transaction; may not be null * @param request the resolved request; may not be null and must contain a repository name and workspace name * @return the node; never null * @throws RepositoryException if the node does not exist, or if there is another problem obtaining the node */ private Node nodeFor( ITransaction transaction, ResolvedRequest request ) throws RepositoryException { return ((JcrSessionTransaction)transaction).nodeFor(request); } /** * Determine if the repository and/or workspace named in the supplied request do exist. * * @param transaction the active transaction; may not be null * @param request the resolved request; may not be null and must contain a repository name and workspace name * @return true if the repository and/or workspace do exist, or false otherwise * @throws RepositoryException if is a problem accessing the repository */ private boolean repositoryAndWorkspaceExist( ITransaction transaction, ResolvedRequest request ) throws RepositoryException { return ((JcrSessionTransaction)transaction).repositoryAndWorkspaceExist(request); } /** * Determine the names of the children given the supplied request * * @param transaction the active transaction; may not be null * @param request the resolved request; may not be null and must contain a repository name and workspace name * @return the children names, or null if there are no children * @throws RepositoryException if is a problem accessing the repository */ private String[] childrenFor( ITransaction transaction, ResolvedRequest request ) throws RepositoryException { return ((JcrSessionTransaction)transaction).childrenFor(request); } private void markForRollback( ITransaction transaction, ResolvedRequest request ) { ((JcrSessionTransaction)transaction).markForRollback(request); } private Session sessionFor( ITransaction transaction, ResolvedRequest request ) throws RepositoryException { return ((JcrSessionTransaction)transaction).session(request); } protected final Logger logger() { return logger; } /** * Implementation of the {@link ITransaction} interface that uses a {@link Session JCR session} to load and store webdav * content. The session also provides support for transactional access to the underlying store. */ class JcrSessionTransaction implements ITransaction { private final Map<SessionKey, Session> sessions = new HashMap<SessionKey, Session>(); private final Set<SessionKey> sessionsMarkedForRollback = new HashSet<SessionKey>(); private final Principal principal; JcrSessionTransaction( Principal principal ) { this.principal = principal; } protected boolean owns( Session session ) { return sessions.containsValue(session); } /** * @param request the resolved request; may not be null * @return the session associated with this transaction; never null * @throws RepositoryException if there is a problem obtaining a repository session for the request */ Session session( ResolvedRequest request ) throws RepositoryException { String repositoryName = request.getRepositoryName(); String workspaceName = request.getWorkspaceName(); assert repositoryName != null; assert workspaceName != null; SessionKey key = new SessionKey(repositoryName, workspaceName); Session result = sessions.get(key); if (result == null) { try { result = RepositoryManager.getSession(request.getRequest(), repositoryName, workspaceName); } catch (RepositoryException e) { logger().warn(WebdavI18n.cannotGetRepositorySession, repositoryName, e.getMessage()); throw translate(e); } sessions.put(key, result); } return result; } Node nodeFor( ResolvedRequest request ) throws RepositoryException { Session session = session(request); Item item = session.getItem(request.getPath()); if (item instanceof Property) { throw new WebdavException(WebdavI18n.errorPropertyPath.text(item.getPath())); } return (Node)item; } void markForRollback( ResolvedRequest request ) { sessionsMarkedForRollback.add(new SessionKey(request.getRepositoryName(), request.getWorkspaceName())); } Map<String, String> namespacesFor( ResolvedRequest request ) throws RepositoryException { Session session = session(request); Map<String, String> namespaces = new HashMap<String, String>(); for (String namespacePrefix : session.getNamespacePrefixes()) { if (StringUtil.isBlank(namespacePrefix) || EXCLUDED_NAMESPACE_PREFIXES.contains(namespacePrefix.toLowerCase())) { continue; } String namespaceURI = session.getNamespaceURI(namespacePrefix); namespaces.put(namespaceURI, namespacePrefix); } return namespaces; } boolean repositoryAndWorkspaceExist( ResolvedRequest request ) throws RepositoryException { assert request != null; if (request.getRepositoryName() != null) { if (request.getWorkspaceName() != null) { try { session(request); return true; } catch (NoSuchWorkspaceException e) { // the workspace does not exist ... return false; } } // See if the repository exists ... return RepositoryManager.getJcrRepositoryNames().contains(request.getRepositoryName()); } // Otherwise, the request doesn't even specify the repository name, so we'll treat this as existing ... return true; } String[] childrenFor( ResolvedRequest request ) throws RepositoryException { assert request != null; Collection<String> names = null; if (request.getRepositoryName() != null) { if (request.getWorkspaceName() != null) { try { Session session = session(request); names = namesOfChildren(session.getRootNode()); } catch (NoSuchWorkspaceException e) { // the workspace does not exist ... return null; } } else { // Get the list of accessible workspaces ... // First look in a session for the same repository ... String repositoryName = request.getRepositoryName(); for (Map.Entry<SessionKey, Session> entry : sessions.entrySet()) { SessionKey key = entry.getKey(); if (!repositoryName.equals(key.repositoryName)) { continue; } Session session = entry.getValue(); try { return session.getWorkspace().getAccessibleWorkspaceNames(); } catch (RepositoryException e) { // try another session ... } } // Didn't have an existing session for that repository, so create one ... Session session = null; try { session = RepositoryManager.getSession(request.getRequest(), repositoryName, null); return session.getWorkspace().getAccessibleWorkspaceNames(); } catch (RepositoryException e) { logger().warn(WebdavI18n.cannotGetRepositorySession, repositoryName, e.getMessage()); throw translate(e); } finally { if (session != null) { session.logout(); // always terminate this session! } } } } else { // Get the list of repository names ... names = RepositoryManager.getJcrRepositoryNames(); } return names == null ? null : names.toArray(new String[names.size()]); } @Override public Principal getPrincipal() { return principal; } /** * Commits any pending changes to the underlying store. */ void commit() { try { for (Map.Entry<SessionKey, Session> entry : sessions.entrySet()) { if (!sessionsMarkedForRollback.contains(entry.getKey())) { entry.getValue().save(); } } } catch (RepositoryException re) { throw new WebdavException(re); } finally { for (Session session : sessions.values()) { try { session.logout(); } catch (Throwable t) { // do nothing } } sessions.clear(); sessionsMarkedForRollback.clear(); } } } /** * Converts the JCR Exceptions to WebDAV ones. * * @param exception the repository exception * @return the WebDAV exception */ protected WebdavException translate( RepositoryException exception ) { return ModeShapeWebdavServlet.translateError(exception); } protected static final class SessionKey { protected final String repositoryName; protected final String workspaceName; protected SessionKey( String repositoryName, String workspaceName ) { this.repositoryName = repositoryName; this.workspaceName = workspaceName; assert this.repositoryName != null; assert this.workspaceName != null; } @Override public int hashCode() { return repositoryName.hashCode(); } @Override public boolean equals( Object obj ) { if (obj == this) return true; if (obj instanceof SessionKey) { SessionKey that = (SessionKey)obj; return this.repositoryName.equals(that.repositoryName) && this.workspaceName.equals(that.workspaceName); } return false; } @Override public String toString() { return repositoryName + "/" + workspaceName; } } }