Permissions.java

/*
 * 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
 *
 *      https://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.commons.jexl3.internal.introspection;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiPredicate;

import org.apache.commons.jexl3.annotations.NoJexl;
import org.apache.commons.jexl3.introspection.JexlPermissions;

/**
 * Checks whether an element (ctor, field, or method) is visible by JEXL introspection.
 * <p>The default implementation does this by checking if an element has been annotated with NoJexl.</p>
 *
 * <p>The NoJexl annotation allows a fine grain permission on executable objects (methods, fields, constructors).
 * </p>
 * <ul>
 * <li>NoJexl of a package implies all classes (including derived classes), and all interfaces
 * of that package are invisible to JEXL.</li>
 * <li>NoJexl on a class implies this class, and all its derived classes are invisible to JEXL.</li>
 * <li>NoJexl on a (public) field makes it not visible as a property to JEXL.</li>
 * <li>NoJexl on a constructor prevents that constructor to be used to instantiate through 'new'.</li>
 * <li>NoJexl on a method prevents that method and any of its overrides to be visible to JEXL.</li>
 * <li>NoJexl on an interface prevents all methods of that interface and their overrides to be visible to JEXL.</li>
 * </ul>
 * <p>It is possible to define permissions on external library classes used for which the source code
 * cannot be altered using an instance of permissions using {@link JexlPermissions#parse(String...)}.</p>
 */
public class Permissions implements JexlPermissions {
    /**
     * Represents the ability to create a copy of an object.
     * Any class implementing this interface must provide a concrete
     * implementation for the {@code copy()} method, which returns
     * a new instance of the object that is a logical copy of the original.
     *
     * @param <T> the type of object that can be copied
     */
    interface Copyable<T> {
        T copy() ;
    }

    /**
     * Creates a copy of a map containing copyable values.
     * @param map the map to copy
     * @return the copy of the map
     * @param <T> the type of Copyable values
     */
    static <T extends Copyable<T>> Map<String, T> copyMap(Map<String, T> map) {
        Map<String, T> njc = new HashMap<>(map.size());
        for(Map.Entry<String, T> entry : map.entrySet()) {
            njc.put(entry.getKey(), entry.getValue().copy());
        }
        return njc;
    }

    /**
     * Equivalent of @NoJexl on a ctor, a method, or a field in a class.
     * <p>Field or method that are named are denied access.</p>
     */
    static class NoJexlClass implements Copyable<NoJexlClass> {
        // the NoJexl method names (including ctor, name of class)
        final Set<String> methodNames;
        // the NoJexl field names
        final Set<String> fieldNames;

        NoJexlClass() {
            this(new HashSet<>(), new HashSet<>());
        }

        NoJexlClass(final Set<String> methods, final Set<String> fields) {
            methodNames = methods;
            fieldNames = fields;
        }
        
        @Override public NoJexlClass copy() {
            return new NoJexlClass(new HashSet<>(methodNames), new HashSet<>(fieldNames));
        }

        boolean deny(final Constructor<?> method) {
            return methodNames.contains(method.getDeclaringClass().getSimpleName());
        }

        boolean deny(final Field field) {
            return isEmpty() || fieldNames.contains(field.getName());
        }

        boolean deny(final Method method) {
            return isEmpty() || methodNames.contains(method.getName());
        }

        boolean isEmpty() { return methodNames.isEmpty() && fieldNames.isEmpty(); }
    }

    /**
     * A positive NoJexl construct that defines what is denied by absence in the set.
     * <p>Field or method that are named are the only one allowed access.</p>
     */
    static class JexlClass extends NoJexlClass {
        JexlClass(Set<String> methods, Set<String> fields) {
            super(methods, fields);
        }
        JexlClass() {
            super();
        }
        @Override public JexlClass copy() {
            return new JexlClass(new HashSet<>(methodNames), new HashSet<>(fieldNames));
        }
        @Override boolean deny(final Constructor<?> method) { return !super.deny(method); }
        @Override boolean deny(final Field field) { return !super.deny(field); }
        @Override boolean deny(final Method method) { return !super.deny(method); }
    }

    /**
     * Equivalent of @NoJexl on a class in a package.
     */
    protected static class NoJexlPackage implements Copyable<NoJexlPackage> {
        // the NoJexl class names
        final Map<String, NoJexlClass> nojexl;

        /**
         * Default ctor.
         */
        NoJexlPackage() {
            this(null);
        }

        /**
         * Ctor.
         *
         * @param map the map of NoJexl classes
         */
        NoJexlPackage(final Map<String, NoJexlClass> map) {
            this.nojexl = map == null || map.isEmpty() ? new HashMap<>() : map;
        }

        void addNoJexl(final String key, final NoJexlClass njc) {
            if (njc == null) {
                nojexl.remove(key);
            } else {
                nojexl.put(key, njc);
            }
        }

        NoJexlClass getNoJexl(final Class<?> clazz) {
            return nojexl.get(classKey(clazz));
        }

        boolean isEmpty() { return nojexl.isEmpty(); }

        /**
         * Whether this package has at least one explicitly-allowed class (a {@code JexlClass} entry).
         * <p>A package with allowed-class entries acts as an allow-list: unlisted classes are denied.
         * A package with only denied-class entries acts as a deny-list: unlisted classes are allowed.</p>
         *
         * @return true if at least one class is explicitly allowed
         */
        boolean hasAllowedClass() {
            for (final NoJexlClass njc : nojexl.values()) {
                if (njc instanceof JexlClass) {
                    return true;
                }
            }
            return false;
        }

        @Override public NoJexlPackage copy() {
            return new NoJexlPackage(copyMap(nojexl));
        }
    }

    /**
     * A package where classes are allowed by default.
     */
    static class JexlPackage extends NoJexlPackage {
        JexlPackage(Map<String, NoJexlClass> map) {
            super(map);
        }

        @Override
        NoJexlClass getNoJexl(final Class<?> clazz) {
            NoJexlClass njc = nojexl.get(classKey(clazz));
            return njc != null ? njc : JEXL_CLASS;
        }

        @Override public JexlPackage copy() {
            return new JexlPackage(copyMap(nojexl));
        }
    }

    /** Marker for whole NoJexl class. */
    static final NoJexlClass NOJEXL_CLASS = new NoJexlClass(Collections.emptySet(), Collections.emptySet()) {
        @Override boolean deny(final Constructor<?> method) {
            return true;
        }

        @Override boolean deny(final Field field) {
            return true;
        }

        @Override boolean deny(final Method method) {
            return true;
        }
    };

    /** Marker for allowed class. */
    static final NoJexlClass JEXL_CLASS = new JexlClass(Collections.emptySet(), Collections.emptySet()) {
        @Override boolean deny(final Constructor<?> method) {
            return false;
        }

        @Override boolean deny(final Field field) {
            return false;
        }

        @Override  boolean deny(final Method method) {
            return false;
        }
    };

    /** Marker for @NoJexl package. */
    static final NoJexlPackage NOJEXL_PACKAGE = new NoJexlPackage(Collections.emptyMap()) {
        @Override NoJexlClass getNoJexl(final Class<?> clazz) {
            return NOJEXL_CLASS;
        }
    };

    /** Marker for fully allowed package. */
    static final NoJexlPackage JEXL_PACKAGE = new NoJexlPackage(Collections.emptyMap()) {
        @Override NoJexlClass getNoJexl(final Class<?> clazz) {
            return JEXL_CLASS;
        }
    };

    /**
     * The no-restriction introspection permission singleton.
     */
    static final Permissions UNRESTRICTED = new Permissions();

    /**
     * The @NoJexl execution-time map.
     */
    private final Map<String, NoJexlPackage> packages;
    /**
     * The allowed package patterns (wildcards or exact package names).
     * <p>Empty together with an empty {@link #packages} map means open-world: every package is accessible
     * and only explicitly denied elements are carved out — the behavior of {@link #UNRESTRICTED}.
     * Empty with a non-empty {@link #packages} map, or non-empty, means closed-world: only declared
     * packages are accessible.</p>
     */
    private final Set<String> allowed;

    /** Allow inheritance. */
    protected Permissions() {
        this.allowed = Collections.emptySet();
        this.packages = Collections.emptyMap();
    }

    /**
     * Default ctor.
     *
     * @param perimeter the allowed wildcard set of packages
     * @param nojexl the NoJexl external map
     */
    protected Permissions(final Set<String> perimeter, final Map<String, NoJexlPackage> nojexl) {
        this.allowed = perimeter;
        this.packages = nojexl;
    }

    /**
     * Creates a new set of permissions by composing these permissions with a new set of rules.
     *
     * @param src the rules
     * @return the new permissions
     */
    @Override
    public Permissions compose(final String... src) {
        return new PermissionsParser().parse(new HashSet<>(allowed), copyMap(packages), src);
    }

    /**
     * Creates a class key joining enclosing ascendants with '$'.
     * <p>As in {@code outer$inner} for <code>class outer { class inner...</code>.</p>
     *
     * @param clazz the clazz
     * @return the clazz key
     */
    static String classKey(final Class<?> clazz) {
        return classKey(clazz, null);
    }

    /**
     * Creates a class key joining enclosing ascendants with '$'.
     * <p>As in {@code outer$inner} for <code>class outer { class inner...</code>.</p>
     *
     * @param clazz the clazz
     * @param strb the buffer to compose the key
     * @return the clazz key
     */
    static String classKey(final Class<?> clazz, final StringBuilder strb) {
        StringBuilder keyb = strb;
        final Class<?> outer = clazz.getEnclosingClass();
        if (outer != null) {
            if (keyb == null) {
                keyb = new StringBuilder();
            }
            classKey(outer, keyb);
            keyb.append('$');
        }
        if (keyb != null) {
            keyb.append(clazz.getSimpleName());
            return keyb.toString();
        }
        return clazz.getSimpleName();
    }

    /**
     * Whether the wildcard set of packages allows a given package to be introspected.
     *
     * @param allowed the allowed set (not null, may be empty)
     * @param name the package name (not null)
     * @return true if allowed, false otherwise
     */
    static boolean wildcardAllow(final Set<String> allowed, final String name) {
        // allowed packages are explicit in this case
        boolean found = allowed == null || allowed.isEmpty() || allowed.contains(name);
        if (!found) {
            String wildcard = name;
            for (int i = name.length(); !found && i > 0; i = wildcard.lastIndexOf('.')) {
                wildcard = wildcard.substring(0, i);
                found = allowed.contains(wildcard + ".*");
            }
        }
        return found;
    }

    /**
     * Gets the package constraints.
     *
     * @param packageName the package name
     * @return the package constraints instance, not-null.
     */
    private NoJexlPackage getNoJexlPackage(final String packageName) {
        return packages.getOrDefault(packageName, JEXL_PACKAGE);
    }

    /**
     * @return the packages
     */
    Map<String, NoJexlPackage> getPackages() {
        return packages == null ? Collections.emptyMap() : Collections.unmodifiableMap(packages);
    }

    /**
     * @return the wildcards
     */
    Set<String> getWildcards() {
        return allowed == null ? Collections.emptySet() : Collections.unmodifiableSet(allowed);
    }

    /**
     * Whether a package belongs to the allowed perimeter.
     * <p>Open-world ({@link #UNRESTRICTED}: no rules at all) allows every package. Closed-world requires the
     * package to match an entry in {@link #allowed}; an empty perimeter in closed-world matches nothing.</p>
     *
     * @param packageName the package name (not null)
     * @return true if allowed, false otherwise
     */
    private boolean allowedPackage(final String packageName) {
        if (allowed.isEmpty() && packages.isEmpty()) {
            return true;
        }
        return !allowed.isEmpty() && wildcardAllow(allowed, packageName);
    }

    /**
     * Whether the wildcard set of packages allows a given class to be introspected.
     *
     * @param clazz the package name (not null)
     * @return true if allowed, false otherwise
     */
    private boolean wildcardAllow(final Class<?> clazz) {
        return allowedPackage(ClassTool.getPackageName(clazz));
    }

    /**
     * Determines whether a specified permission check is allowed for a given class.
     * The check involves verifying if a class or its corresponding package explicitly permits
     * a name (e.g., method) based on a given condition.
     *
     * @param <T> the type of the name to check (e.g., method, constructor)
     * @param clazz the class to evaluate (not null)
     * @param name the name to verify (not null)
     * @param check the condition to test whether the specified name is allowed (not null)
     * @return true if the specified name is allowed based on the condition, false otherwise
     */
    private <T> boolean specifiedAllow(final Class<?> clazz, T name, BiPredicate<NoJexlClass, T> check) {
        final String packageName = ClassTool.getPackageName(clazz);
        if (allowedPackage(packageName)) {
            return true;
        }
        final NoJexlPackage njp = packages.get(packageName);
        if (njp != null && check != null) {
            // there is a package permission, check if there is a class permission
            final NoJexlClass njc = njp.getNoJexl(clazz);
            if (njc != null) {
                return check.test(njc, name);
            }
            // class not listed: allowed if the package is a deny-list (no explicit class allows);
            // denied if the package is an allow-list (e.g. java.io -{ +PrintWriter{} ... })
            return !njp.hasAllowedClass();
        }
        // package not declared at all
        return false;
    }

    /**
     * Checks whether a class or one of its super-classes or implemented interfaces
     * explicitly allows JEXL introspection.
     *
     * @param clazz the class to check
     * @return true if JEXL is allowed to introspect, false otherwise
     */
    @Override
    public boolean allow(final Class<?> clazz) {
        // clazz must be not null
        if (!validate(clazz)) {
            return false;
        }
        // proxy goes through
        if (Proxy.isProxyClass(clazz)) {
            return true;
        }
        // class must be allowed
        if (deny(clazz)) {
            return false;
        }
        // no super class can be denied and at least one must be allowed
        boolean explicit = specifiedAllow(clazz, clazz, (njc, c) -> njc instanceof JexlClass);
        Class<?> walk = clazz.getSuperclass();
        while (walk != null) {
            if (deny(walk)) {
                return false;
            }
            if (!explicit) {
                explicit = wildcardAllow(walk);
            }
            walk = walk.getSuperclass();
        }
        // check wildcards
        return explicit;
    }

    /**
     * Check whether a method is allowed to be introspected in one superclass or interface.
     *
     * @param clazz the superclass or interface to check
     * @param method the method
     * @param explicit carries whether the package holding the method is explicitly allowed
     * @return true if JEXL is allowed to introspect, false otherwise
     */
    private boolean allow(final Class<?> clazz, final Method method, final boolean[] explicit) {
        try {
            // check if the method in that class is declared thus overrides
            final Method override = clazz.getDeclaredMethod(method.getName(), method.getParameterTypes());
            if (override != method) {
                // should not be possible...
                if (denyMethod(override)) {
                    return false;
                }
                // explicit |= ...
                if (!explicit[0]) {
                    explicit[0] = specifiedAllow(clazz, override, (njc, m) -> !njc.deny(m));
                }
            }
            return true;
        } catch (final NoSuchMethodException ex) {
            // will happen if not overriding method in clazz
            return true;
        } catch (final SecurityException ex) {
            // unexpected, can't do much
            return false;
        }
    }

    /**
     * Checks whether a constructor explicitly allows JEXL introspection.
     *
     * @param ctor the constructor to check
     * @return true if JEXL is allowed to introspect, false otherwise
     */
    @Override
    public boolean allow(final Constructor<?> ctor) {
        // method must be not null, public
        if (!validate(ctor)) {
            return false;
        }
        // check declared restrictions
        if (deny(ctor)) {
            return false;
        }
        // class must agree
        final Class<?> clazz = ctor.getDeclaringClass();
        if (deny(clazz)) {
            return false;
        }
        // check wildcards
        return specifiedAllow(clazz, clazz, (njc, c) -> !njc.deny(ctor));
    }


    /**
     * Checks whether a field explicitly allows JEXL introspection.
     *
     * @param field the field to check
     * @return true if JEXL is allowed to introspect, false otherwise
     */
    @Override
    public boolean allow(final Field field) {
        // field must be public
        if (!validate(field)) {
            return false;
        }
        // check declared restrictions
        if (deny(field)) {
            return false;
        }
        // class must agree
        final Class<?> clazz = field.getDeclaringClass();
        if (deny(clazz)) {
            return false;
        }
        // check wildcards
        return specifiedAllow(clazz, field, (njc, m) -> !njc.deny(m));
    }

    @Override
    public boolean allow(final Class<?> clazz, final Method method) {
        if (!validate(clazz) || !validate(method)) {
            return false;
        }
        if ((method.getModifiers() & Modifier.STATIC) == 0) {
            Class<?> declaring = method.getDeclaringClass();
            if (clazz != declaring) {
                if (deny(clazz)) {
                    return false;
                }
                // just check this is an override of a method in clazz, if not, it is not allowed (obviously)
                if (!declaring.isAssignableFrom(clazz)) {
                    return false;
                }
                // if there is an explicit permission, allow if not denied
                NoJexlClass njc = getNoJexl(clazz, null);
                if (njc != null) {
                    return !njc.deny(method);
                }
            }
        }
        return allow(method);
    }

    @Override
    public boolean allow(final Class<?> clazz, final Field field) {
        if (!validate(clazz) || !validate(field)) {
            return false;
        }
        if ((field.getModifiers() & Modifier.STATIC) == 0) {
            Class<?> declaring = field.getDeclaringClass();
            if (clazz != declaring) {
                if (deny(clazz)) {
                    return false;
                }
                // just check this clazz extends/inherits from declaring, if not, it is not allowed (obviously)
                if (!declaring.isAssignableFrom(clazz)) {
                    return false;
                }
                // if there is an explicit permission, allow if not denied
                NoJexlClass njc = getNoJexl(clazz, null);
                if (njc != null) {
                    return !njc.deny(field);
                }
            }
        }
        return allow(field);
    }

    /**
     * Checks whether a method explicitly allows JEXL introspection.
     * <p>Since methods can be overridden, this also checks that no superclass or interface
     * explicitly disallows this method.</p>
     *
     * @param method the method to check
     * @return true if JEXL is allowed to introspect, false otherwise
     */
    @Override
    public boolean allow(final Method method) {
        // method must be not null, public, not synthetic, not bridge
        if (!validate(method)) {
            return false;
        }
        // method must be allowed
        if (denyMethod(method)) {
            return false;
        }
        Class<?> clazz = method.getDeclaringClass();
        // gather if the packages explicitly allow any implementation of the method
        final boolean[] explicit = { specifiedAllow(clazz, method, (njc, m) -> !njc.deny(m)) };
        // let's walk all interfaces
        for (final Class<?> inter : clazz.getInterfaces()) {
            if (!allow(inter, method, explicit)) {
                return false;
            }
        }
        // let's walk all super classes
        clazz = clazz.getSuperclass();
        while (clazz != null) {
            if (!allow(clazz, method, explicit)) {
                return false;
            }
            clazz = clazz.getSuperclass();
        }
        return explicit[0];
    }

    /**
     * Checks whether a package explicitly disallows JEXL introspection.
     *
     * @param pack the package
     * @return true if JEXL is allowed to introspect, false otherwise
     */
    @Override
    public boolean allow(final Package pack) {
        // field must be public
        if (!validate(pack)) {
            return false;
        }
        // check declared restrictions
        if (deny(pack)) {
            return false;
        }
        // an explicit package entry is allowed unless it is the deny marker
        final String name = pack.getName();
        final NoJexlPackage njp = packages.get(name);
        return njp == null ? allowedPackage(name) : !Objects.equals(NOJEXL_PACKAGE, njp);
    }


    /**
     * Tests whether a whole class is denied Jexl visibility.
     * <p>Also checks package visibility.</p>
     *
     * @param clazz the class
     * @return true if denied, false otherwise
     */
    private boolean deny(final Class<?> clazz) {
        // Don't deny arrays
        if (clazz.isArray()) {
            return false;
        }
        // is clazz annotated with nojexl ?
        final NoJexl nojexl = clazz.getAnnotation(NoJexl.class);
        if (nojexl != null) {
            return true;
        }
        final NoJexlPackage njp = packages.get(ClassTool.getPackageName(clazz));
        return njp != null && Objects.equals(NOJEXL_CLASS, njp.getNoJexl(clazz));
    }

    /**
     * Tests whether a constructor is denied Jexl visibility.
     *
     * @param ctor the constructor
     * @return true if denied, false otherwise
     */
    private boolean deny(final Constructor<?> ctor) {
        // is ctor annotated with nojexl ?
        final NoJexl nojexl = ctor.getAnnotation(NoJexl.class);
        if (nojexl != null) {
            return true;
        }
        return getNoJexl(ctor.getDeclaringClass()).deny(ctor);
    }

    /**
     * Tests whether a field is denied Jexl visibility.
     *
     * @param field the field
     * @return true if denied, false otherwise
     */
    private boolean deny(final Field field) {
        // is field annotated with nojexl ?
        final NoJexl nojexl = field.getAnnotation(NoJexl.class);
        if (nojexl != null) {
            return true;
        }
        return getNoJexl(field.getDeclaringClass()).deny(field);
    }

    /**
     * Tests whether a method is denied Jexl visibility.
     *
     * @param method the method
     * @return true if denied, false otherwise
     */
    private boolean deny(final Method method) {
        // is method annotated with nojexl ?
        final NoJexl nojexl = method.getAnnotation(NoJexl.class);
        if (nojexl != null) {
            return true;
        }
        return getNoJexl(method.getDeclaringClass()).deny(method);
    }

    /**
     * Tests whether a whole package is denied Jexl visibility.
     *
     * @param pack the package
     * @return true if denied, false otherwise
     */
    private boolean deny(final Package pack) {
        // is package annotated with nojexl ?
        final NoJexl nojexl = pack.getAnnotation(NoJexl.class);
        if (nojexl != null) {
            return true;
        }
        return Objects.equals(NOJEXL_PACKAGE, packages.get(pack.getName()));
    }

    /**
     * Tests whether a method is denied.
     *
     * @param method the method
     * @return true if it has been disallowed through annotation or declaration
     */
    private boolean denyMethod(final Method method) {
        // check declared restrictions, class must not be denied
        return deny(method) || deny(method.getDeclaringClass());
    }

    /**
     * Gets the class constraints.
     * <p>If nothing was explicitly forbidden, everything is allowed.</p>
     *
     * @param clazz the class
     * @return the class constraints instance, not-null.
     */
    private NoJexlClass getNoJexl(final Class<?> clazz) {
        return getNoJexl(clazz, JEXL_CLASS);
    }
    private NoJexlClass getNoJexl(final Class<?> clazz, NoJexlClass ifNone) {
        final String pkgName = ClassTool.getPackageName(clazz);
        final NoJexlPackage njp = getNoJexlPackage(pkgName);
        if (njp != null) {
            final NoJexlClass njc = njp.getNoJexl(clazz);
            if (njc != null) {
                return njc;
            }
        }
        return ifNone;
    }
}