/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.apache.jackrabbit.oak.spi.security.authentication.external.impl;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.jcr.Binary;
import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.ValueFactory;
import org.apache.felix.scr.annotations.Activate;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.ConfigurationPolicy;
import org.apache.felix.scr.annotations.Service;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.Group;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.commons.iterator.AbstractLazyIterator;
import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalGroup;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentity;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityException;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProvider;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalUser;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncContext;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncException;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncHandler;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncResult;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncedIdentity;
import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@code DefaultSyncHandler} implements an sync handler that synchronizes users and groups from an external identity
* provider with the repository users.
* <p/>
* Please refer to {@link DefaultSyncConfig} for configuration options.
*/
@Component(
// note that the metatype information is generated from DefaultSyncConfig
policy = ConfigurationPolicy.REQUIRE
)
@Service
public class DefaultSyncHandler implements SyncHandler {
/**
* logger instance
*/
private static final Logger log = LoggerFactory.getLogger(DefaultSyncHandler.class);
/**
* Name of the {@link ExternalIdentity#getExternalId()} property of a synchronized identity.
*/
public static final String REP_EXTERNAL_ID = "rep:externalId";
/**
* Name of the property that stores the time when an identity was synced.
*/
public static final String REP_LAST_SYNCED = "rep:lastSynced";
/**
* internal configuration
*/
private DefaultSyncConfig config;
/**
* Default constructor for OSGi
*/
@SuppressWarnings("UnusedDeclaration")
public DefaultSyncHandler() {
}
/**
* Constructor for non-OSGi cases.
*
* @param config the configuration
*/
public DefaultSyncHandler(DefaultSyncConfig config) {
this.config = config;
}
@Activate
private void activate(Map<String, Object> properties) {
ConfigurationParameters cfg = ConfigurationParameters.of(properties);
config = DefaultSyncConfig.of(cfg);
}
/**
* {@inheritDoc}
*/
@Nonnull
@Override
public String getName() {
return config.getName();
}
/**
* {@inheritDoc}
*/
@Nonnull
@Override
public SyncContext createContext(@Nonnull ExternalIdentityProvider idp, @Nonnull UserManager userManager,
@Nonnull ValueFactory valueFactory) throws SyncException {
return new ContextImpl(idp, userManager, valueFactory);
}
/**
* {@inheritDoc}
*/
@Override
public SyncedIdentity findIdentity(@Nonnull UserManager userManager, @Nonnull String id)
throws RepositoryException {
return createSyncedIdentity(userManager.getAuthorizable(id));
}
/**
* {@inheritDoc}
*/
@Override
public Iterator<SyncedIdentity> listIdentities(@Nonnull UserManager userManager) throws RepositoryException {
final Iterator<Authorizable> iter = userManager.findAuthorizables("jcr:primaryType", null);
return new AbstractLazyIterator<SyncedIdentity>() {
@Override
protected SyncedIdentity getNext() {
while (iter.hasNext()) {
try {
SyncedIdentity id = createSyncedIdentity(iter.next());
if (id != null) {
return id;
}
} catch (RepositoryException e) {
log.error("Error while fetching authorizables", e);
break;
}
}
return null;
}
};
}
/**
* Creates a synced identity from the given authorizable.
* @param auth the authorizable
* @return the id
* @throws RepositoryException if an error occurrs
*/
@CheckForNull
private static SyncedIdentityImpl createSyncedIdentity(@Nullable Authorizable auth) throws RepositoryException {
ExternalIdentityRef ref = auth == null ? null : getIdentityRef(auth);
if (ref == null) {
return null;
} else {
Value[] lmValues = auth.getProperty(REP_LAST_SYNCED);
long lastModified = -1;
if (lmValues != null && lmValues.length > 0) {
lastModified = lmValues[0].getLong();
}
return new SyncedIdentityImpl(auth.getID(), ref, auth.isGroup(), lastModified);
}
}
/**
* Internal implementation of the sync context
*/
private class ContextImpl implements SyncContext {
private final ExternalIdentityProvider idp;
private final UserManager userManager;
private final ValueFactory valueFactory;
private boolean keepMissing;
private boolean forceUserSync;
private boolean forceGroupSync;
// we use the same wall clock for the entire context
private final long now;
private final Value nowValue;
private ContextImpl(ExternalIdentityProvider idp, UserManager userManager, ValueFactory valueFactory) {
this.idp = idp;
this.userManager = userManager;
this.valueFactory = valueFactory;
// initialize 'now'
final Calendar nowCal = Calendar.getInstance();
this.nowValue = valueFactory.createValue(nowCal);
this.now = nowCal.getTimeInMillis();
}
/**
* {@inheritDoc}
*/
@Override
public void close() {
// nothing to do
}
/**
* {@inheritDoc}
*/
@Override
public boolean isKeepMissing() {
return keepMissing;
}
/**
* {@inheritDoc}
*/
@Override
public SyncContext setKeepMissing(boolean keepMissing) {
this.keepMissing = keepMissing;
return this;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isForceUserSync() {
return forceUserSync;
}
/**
* {@inheritDoc}
*/
@Override
public SyncContext setForceUserSync(boolean forceUserSync) {
this.forceUserSync = forceUserSync;
return this;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isForceGroupSync() {
return forceGroupSync;
}
public SyncContext setForceGroupSync(boolean forceGroupSync) {
this.forceGroupSync = forceGroupSync;
return this;
}
/**
* {@inheritDoc}
*/
@Override
public SyncResult sync(@Nonnull ExternalIdentity identity) throws SyncException {
try {
DebugTimer timer = new DebugTimer();
SyncResultImpl ret;
boolean created = false;
if (identity instanceof ExternalUser) {
User user = getAuthorizable(identity, User.class);
timer.mark("find");
if (user == null) {
user = createUser((ExternalUser) identity);
timer.mark("create");
created = true;
}
ret = syncUser((ExternalUser) identity, user);
timer.mark("sync");
} else if (identity instanceof ExternalGroup) {
Group group = getAuthorizable(identity, Group.class);
timer.mark("find");
if (group == null) {
group = createGroup((ExternalGroup) identity);
timer.mark("create");
created = true;
}
ret = syncGroup((ExternalGroup) identity, group);
timer.mark("sync");
} else {
throw new IllegalArgumentException("identity must be user or group but was: " + identity);
}
if (log.isDebugEnabled()) {
log.debug("sync({}) -> {} {}", identity.getExternalId().getString(), identity.getId(), timer.getString());
}
if (created) {
ret.setStatus(SyncResult.Status.ADD);
}
return ret;
} catch (RepositoryException e) {
throw new SyncException(e);
}
}
/**
* {@inheritDoc}
*/
@Override
public SyncResult sync(@Nonnull String id) throws SyncException {
try {
DebugTimer timer = new DebugTimer();
SyncResultImpl ret;
// find authorizable
Authorizable auth = userManager.getAuthorizable(id);
if (auth == null) {
return new SyncResultImpl(new SyncedIdentityImpl(id, null, false, -1), SyncResult.Status.NO_SUCH_AUTHORIZABLE);
}
// check if we need to deal with this authorizable
ExternalIdentityRef ref = getIdentityRef(auth);
if (ref == null || !idp.getName().equals(ref.getProviderName())) {
return new SyncResultImpl(new SyncedIdentityImpl(id, null, false, -1), SyncResult.Status.FOREIGN);
}
if (auth instanceof Group) {
Group group = (Group) auth;
ExternalGroup external = idp.getGroup(id);
timer.mark("retrieve");
if (external == null) {
SyncedIdentityImpl syncId = createSyncedIdentity(auth);
if (group.getDeclaredMembers().hasNext()) {
log.info("won't remove local group with members: {}", id);
ret = new SyncResultImpl(syncId, SyncResult.Status.NOP);
} else if (!keepMissing) {
auth.remove();
log.debug("removing authorizable '{}' that no longer exists on IDP {}", id, idp.getName());
timer.mark("remove");
ret = new SyncResultImpl(syncId, SyncResult.Status.DELETE);
} else {
ret = new SyncResultImpl(syncId, SyncResult.Status.MISSING);
log.info("external identity missing for {}, but purge == false.", id);
}
} else {
ret = syncGroup(external, group);
timer.mark("sync");
}
} else {
ExternalUser external = idp.getUser(id);
timer.mark("retrieve");
if (external == null) {
SyncedIdentityImpl syncId = createSyncedIdentity(auth);
if (!keepMissing) {
auth.remove();
log.debug("removing authorizable '{}' that no longer exists on IDP {}", id, idp.getName());
timer.mark("remove");
ret = new SyncResultImpl(syncId, SyncResult.Status.DELETE);
} else {
ret = new SyncResultImpl(syncId, SyncResult.Status.MISSING);
log.info("external identity missing for {}, but purge == false.", id);
}
} else {
ret = syncUser(external, (User) auth);
timer.mark("sync");
}
}
if (log.isDebugEnabled()) {
log.debug("sync({}) -> {} {}", id, ref.getString(), timer.getString());
}
return ret;
} catch (RepositoryException e) {
throw new SyncException(e);
} catch (ExternalIdentityException e) {
throw new SyncException(e);
}
}
/**
* Retrieves the repository authorizable that corresponds to the given external identity
* @param external the external identity
* @param type the authorizable type
* @return the repository authorizable or {@code null} if not found.
* @throws RepositoryException if an error occurs.
* @throws SyncException if the repository contains a colliding authorizable with the same name.
*/
@CheckForNull
private <T extends Authorizable> T getAuthorizable(@Nonnull ExternalIdentity external, Class<T> type)
throws RepositoryException, SyncException {
Authorizable authorizable = userManager.getAuthorizable(external.getId());
if (authorizable == null) {
authorizable = userManager.getAuthorizable(external.getPrincipalName());
}
if (authorizable == null) {
return null;
} else if (type.isInstance(authorizable)) {
//noinspection unchecked
return (T) authorizable;
} else {
log.error("Unable to process external {}: {}. Colliding authorizable exists in repository.", type.getSimpleName(), external.getId());
throw new SyncException("Unexpected authorizable: " + authorizable);
}
}
/**
* Creates a new repository user for the given external one.
* Note that this method only creates the authorizable but does not perform any synchronization.
*
* @param externalUser the external user
* @return the repository user
* @throws RepositoryException if an error occurs
*/
@CheckForNull
private User createUser(ExternalUser externalUser) throws RepositoryException {
Principal principal = new PrincipalImpl(externalUser.getPrincipalName());
User user = userManager.createUser(
externalUser.getId(),
null,
principal,
joinPaths(config.user().getPathPrefix(), externalUser.getIntermediatePath())
);
user.setProperty(REP_EXTERNAL_ID, valueFactory.createValue(externalUser.getExternalId().getString()));
return user;
}
/**
* Creates a new repository group for the given external one.
* Note that this method only creates the authorizable but does not perform any synchronization.
*
* @param externalGroup the external group
* @return the repository group
* @throws RepositoryException if an error occurs
*/
@CheckForNull
private Group createGroup(ExternalGroup externalGroup) throws RepositoryException {
Principal principal = new PrincipalImpl(externalGroup.getPrincipalName());
Group group = userManager.createGroup(
externalGroup.getId(),
principal,
joinPaths(config.group().getPathPrefix(), externalGroup.getIntermediatePath())
);
group.setProperty(REP_EXTERNAL_ID, valueFactory.createValue(externalGroup.getExternalId().getString()));
return group;
}
private SyncResultImpl syncUser(@Nonnull ExternalUser external, @Nonnull User user) throws RepositoryException {
// first check if user is expired
if (!forceUserSync && !isExpired(user, config.user().getExpirationTime(), "Properties")) {
SyncedIdentityImpl syncId = createSyncedIdentity(user);
return new SyncResultImpl(syncId, SyncResult.Status.NOP);
}
// synchronize the properties
syncProperties(external, user, config.user().getPropertyMapping());
// synchronize auto-group membership
applyMembership(user, config.user().getAutoMembership());
if (isExpired(user, config.user().getMembershipExpirationTime(), "Membership")) {
// synchronize external memberships
syncMembership(external, user, config.user().getMembershipNestingDepth());
}
// finally "touch" the sync property
user.setProperty(REP_LAST_SYNCED, nowValue);
SyncedIdentityImpl syncId = createSyncedIdentity(user);
return new SyncResultImpl(syncId, SyncResult.Status.UPDATE);
}
private SyncResultImpl syncGroup(ExternalGroup external, Group group) throws RepositoryException {
// first check if user is expired
if (!forceGroupSync && !isExpired(group, config.group().getExpirationTime(), "Properties")) {
SyncedIdentityImpl syncId = createSyncedIdentity(group);
return new SyncResultImpl(syncId, SyncResult.Status.NOP);
}
// synchronize the properties
syncProperties(external, group, config.group().getPropertyMapping());
// synchronize auto-group membership
applyMembership(group, config.group().getAutoMembership());
// finally "touch" the sync property
group.setProperty(REP_LAST_SYNCED, nowValue);
SyncedIdentityImpl syncId = createSyncedIdentity(group);
return new SyncResultImpl(syncId, SyncResult.Status.UPDATE);
}
/**
* Recursively sync the memberships of an authorizable up-to the specified depth. If the given depth
* is equal or less than 0, no syncing is performed.
*
* @param external the external identity
* @param auth the authorizable
* @param depth recursion depth.
* @throws RepositoryException
*/
private void syncMembership(ExternalIdentity external, Authorizable auth, long depth)
throws RepositoryException {
if (depth <= 0) {
return;
}
if (log.isDebugEnabled()) {
log.debug("Syncing membership '{}' -> '{}'", external.getExternalId().getString(), auth.getID());
}
final DebugTimer timer = new DebugTimer();
Iterable<ExternalIdentityRef> externalGroups;
try {
externalGroups = external.getDeclaredGroups();
} catch (ExternalIdentityException e) {
log.error("Error while retrieving external declared groups for '{}'", external.getId(), e);
return;
}
timer.mark("fetching");
// first get the set of the existing groups that are synced ones
Map<String, Group> declaredExternalGroups = new HashMap<String, Group>();
Iterator<Group> grpIter = auth.declaredMemberOf();
while (grpIter.hasNext()) {
Group grp = grpIter.next();
if (isSameIDP(grp)) {
declaredExternalGroups.put(grp.getID(), grp);
}
}
timer.mark("reading");
for (ExternalIdentityRef ref: externalGroups) {
log.debug("- processing membership {}", ref.getId());
// get group
ExternalGroup extGroup;
try {
extGroup = (ExternalGroup) idp.getIdentity(ref);
} catch (ClassCastException e) {
// this should really not be the case, so catching the CCE is ok here.
log.warn("External identity '{}' is not a group, but should be one.", ref.getString());
continue;
} catch (ExternalIdentityException e) {
log.warn("Unable to retrieve external group '{}' from provider.", ref.getString(), e);
continue;
}
if (extGroup == null) {
log.warn("External group for ref '{}' could not be retrieved from provider.", ref);
continue;
}
log.debug("- idp returned '{}'", extGroup.getId());
Group grp;
try {
grp = (Group) userManager.getAuthorizable(extGroup.getId());
} catch (ClassCastException e) {
// this should really not be the case, so catching the CCE is ok here.
log.warn("Authorizable '{}' is not a group, but should be one.", extGroup.getId());
continue;
}
log.debug("- user manager returned '{}'", grp);
if (grp == null) {
grp = createGroup(extGroup);
log.debug("- created new group");
}
syncGroup(extGroup, grp);
// ensure membership
grp.addMember(auth);
log.debug("- added '{}' as member to '{}'", auth, grp);
// remember the declared group
declaredExternalGroups.remove(grp.getID());
// recursively apply further membership
if (depth > 1) {
log.debug("- recursively sync group membership of '{}' (depth = {}).", grp.getID(), depth);
syncMembership(extGroup, grp, depth - 1);
} else {
log.debug("- group nesting level for '{}' reached", grp.getID());
}
}
timer.mark("adding");
// remove us from the lost membership groups
for (Group grp: declaredExternalGroups.values()) {
grp.removeMember(auth);
log.debug("- removing member '{}' for group '{}'", auth.getID(), grp.getID());
}
if (log.isDebugEnabled()) {
timer.mark("removing");
log.debug("syncMembership({}) {}", external.getId(), timer.getString());
}
}
/**
* Ensures that the given authorizable is member of the specific groups. Note that it does not create groups
* if missing, nor remove memberships of groups not in the given set.
* @param member the authorizable
* @param groups set of groups.
*/
private void applyMembership(Authorizable member, Set<String> groups) throws RepositoryException {
for (String groupName: groups) {
Authorizable group = userManager.getAuthorizable(groupName);
if (group == null) {
log.warn("Unable to apply auto-membership to {}. No such group: {}", member.getID(), groupName);
} else if (group instanceof Group) {
((Group) group).addMember(member);
} else {
log.warn("Unable to apply auto-membership to {}. Authorizable '{}' is not a group.", member.getID(), groupName);
}
}
}
/**
* Syncs the properties specified in the {@code mapping} from the external identity to the given authorizable.
* Note that this method does not check for value equality and just blindly copies or deletes the properties.
*
* @param ext external identity
* @param auth the authorizable
* @param mapping the property mapping
* @throws RepositoryException if an error occurs
*/
private void syncProperties(ExternalIdentity ext, Authorizable auth, Map<String, String> mapping)
throws RepositoryException {
Map<String, ?> properties = ext.getProperties();
for (Map.Entry<String, String> entry: mapping.entrySet()) {
String relPath = entry.getKey();
String name = entry.getValue();
Object obj = properties.get(name);
if (obj == null) {
auth.removeProperty(relPath);
} else {
if (obj instanceof Collection) {
auth.setProperty(relPath, createValues((Collection) obj));
} else if (obj instanceof byte[] || obj instanceof char[]) {
auth.setProperty(relPath, createValue(obj));
} else if (obj instanceof Object[]) {
auth.setProperty(relPath, createValues(Arrays.asList((Object[]) obj)));
} else {
auth.setProperty(relPath, createValue(obj));
}
}
}
}
/**
* Checks if the given authorizable needs syncing based on the {@link #REP_LAST_SYNCED} property.
* @param auth the authorizable to check
* @param expirationTime the expiration time to compare to.
* @param type debug message type
* @return {@code true} if the authorizable needs sync
*/
private boolean isExpired(Authorizable auth, long expirationTime, String type) throws RepositoryException {
Value[] values = auth.getProperty(REP_LAST_SYNCED);
if (values == null || values.length == 0) {
if (log.isDebugEnabled()) {
log.debug("{} of {} '{}' need sync. " + REP_LAST_SYNCED + " not set.",
type, auth.isGroup() ? "group" : "user", auth.getID());
}
return true;
} else if (now - values[0].getLong() > expirationTime) {
if (log.isDebugEnabled()) {
log.debug("{} of {} '{}' need sync. " + REP_LAST_SYNCED + " expired ({} > {})",
type, auth.isGroup() ? "group" : "user", auth.getID(), now - values[0].getLong(), expirationTime);
}
return true;
} else {
if (log.isDebugEnabled()) {
log.debug("{} of {} '{}' do not need sync.", type, auth.isGroup() ? "group" : "user", auth.getID());
}
return false;
}
}
/**
* Creates a new JCR value of the given object, checking the internal type.
* @param v the value
* @return the JCR value or null
* @throws RepositoryException if an error occurs
*/
@CheckForNull
private Value createValue(@Nullable Object v) throws RepositoryException {
if (v == null) {
return null;
} else if (v instanceof Boolean) {
return valueFactory.createValue((Boolean) v);
} else if (v instanceof Byte || v instanceof Short || v instanceof Integer || v instanceof Long) {
return valueFactory.createValue(((Number) v).longValue());
} else if (v instanceof Float || v instanceof Double) {
return valueFactory.createValue(((Number) v).doubleValue());
} else if (v instanceof BigDecimal) {
return valueFactory.createValue((BigDecimal) v);
} else if (v instanceof Calendar) {
return valueFactory.createValue((Calendar) v);
} else if (v instanceof Date) {
Calendar cal = Calendar.getInstance();
cal.setTime((Date) v);
return valueFactory.createValue(cal);
} else if (v instanceof byte[]) {
Binary bin = valueFactory.createBinary(new ByteArrayInputStream((byte[])v));
return valueFactory.createValue(bin);
} else if (v instanceof char[]) {
return valueFactory.createValue(new String((char[]) v));
} else {
return valueFactory.createValue(String.valueOf(v));
}
}
/**
* Creates an array of JCR values based on the type.
* @param propValues the given values
* @return and array of JCR values
* @throws RepositoryException if an error occurs
*/
@CheckForNull
private Value[] createValues(Collection<?> propValues) throws RepositoryException {
List<Value> values = new ArrayList<Value>();
for (Object obj : propValues) {
Value v = createValue(obj);
if (v != null) {
values.add(v);
}
}
return values.toArray(new Value[values.size()]);
}
/**
* Checks if the given authorizable was synced from the same IDP by comparing the IDP name of the
* {@value #REP_EXTERNAL_ID} property.
*
* todo: allow multiple IDPs on 1 authorizable
*
* @param auth the authorizable.
* @return {@code true} if same IDP.
*/
private boolean isSameIDP(@Nullable Authorizable auth) throws RepositoryException {
ExternalIdentityRef ref = getIdentityRef(auth);
return ref != null && idp.getName().equals(ref.getProviderName());
}
}
/**
* Retrieves the external identity ref from the authorizable
* @param auth the authorizable
* @return the ref
* @throws RepositoryException if an error occurs
*/
@CheckForNull
private static ExternalIdentityRef getIdentityRef(@Nullable Authorizable auth) throws RepositoryException {
if (auth == null) {
return null;
}
Value[] v = auth.getProperty(REP_EXTERNAL_ID);
if (v == null || v.length == 0) {
return null;
}
return ExternalIdentityRef.fromString(v[0].getString());
}
/**
* Robust relative path concatenation.
* @param paths relative paths
* @return the concatenated path
*/
private static String joinPaths(String... paths) {
StringBuilder result = new StringBuilder();
for (String path: paths) {
if (path != null && !path.isEmpty()) {
int i0 = 0;
int i1 = path.length();
while (i0 < i1 && path.charAt(i0) == '/') {
i0++;
}
while (i1 > i0 && path.charAt(i1-1) == '/') {
i1--;
}
if (i1 > i0) {
if (result.length() > 0) {
result.append('/');
}
result.append(path.substring(i0, i1));
}
}
}
return result.length() == 0 ? null : result.toString();
}
}