This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new da27f39d5e Add access control when opening a datum shift grid file. 
The operation is denied if the WKT `PARAMETERFILE` value is not relative to the 
`$SIS_DATA/DatumChanges` directory or a sibling of the file that provide the 
WKT definition. This default behavior can be customized.
da27f39d5e is described below

commit da27f39d5e6d7f881ad8044e762753d5440b6dfb
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Tue Sep 22 12:52:15 2026 +0900

    Add access control when opening a datum shift grid file.
    The operation is denied if the WKT `PARAMETERFILE` value
    is not relative to the `$SIS_DATA/DatumChanges` directory
    or a sibling of the file that provide the WKT definition.
    This default behavior can be customized.
---
 .../main/org/apache/sis/xml/bind/Context.java      |  13 +-
 .../main/org/apache/sis/io/wkt/WKTFormat.java      |   5 +-
 .../sis/parameter/DefaultParameterValue.java       |  60 +++---
 .../main/org/apache/sis/parameter/Parameters.java  |   6 +-
 .../referencing/operation/gridded/GridFile.java    | 183 +++++-------------
 .../referencing/operation/gridded/LoadedGrid.java  |   6 +-
 .../provider/FranceGeocentricInterpolation.java    |   2 +-
 .../sis/referencing/operation/provider/NADCON.java |  10 +-
 .../sis/referencing/operation/provider/NTv2.java   |  33 ++--
 .../operation/transform/MathTransformBuilder.java  |  51 ++++-
 .../xml/bind/referencing/CC_OperationMethod.java   |   4 +-
 .../test/org/apache/sis/io/wkt/ElementTest.java    |   3 +-
 .../operation/gridded/GridFileTest.java            | 117 ++++++++++++
 .../FranceGeocentricInterpolationTest.java         |  24 +--
 .../provider/GeocentricTranslationTest.java        |   2 +-
 .../referencing/operation/provider/NADCONTest.java |  21 +-
 .../referencing/operation/provider/NTv2Test.java   |  21 +-
 .../provider/PositionVector7ParamTest.java         |   3 +-
 .../InterpolatedGeocentricTransformTest.java       |  42 ++--
 .../transform/InterpolatedTransformTest.java       |  28 +--
 .../apache/sis/storage/netcdf/MetadataReader.java  |  15 +-
 .../org/apache/sis/storage/base/PRJDataStore.java  |   4 +-
 .../main/org/apache/sis/storage/wkt/Store.java     |   2 +-
 .../org/apache/sis/storage/wkt/StoreFormat.java    |   5 +-
 .../main/org/apache/sis/io/Authorization.java      |  59 ++++++
 .../main/org/apache/sis/io/package-info.java       |  34 ++--
 .../main/org/apache/sis/system/DataURI.java        | 211 +++++++++++++++++++++
 .../main/org/apache/sis/util/resources/Errors.java |   5 +
 .../apache/sis/util/resources/Errors.properties    |   1 +
 .../apache/sis/util/resources/Errors_fr.properties |   3 +-
 30 files changed, 676 insertions(+), 297 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/xml/bind/Context.java
 
b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/xml/bind/Context.java
index 91dd7fa3c0..bdedd3343e 100644
--- 
a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/xml/bind/Context.java
+++ 
b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/xml/bind/Context.java
@@ -159,7 +159,7 @@ public final class Context extends MarshalContext {
      * The base URL of ISO 19115-3 (or other standards) schemas.
      * The valid values are documented in the {@link 
org.apache.sis.xml.XML#SCHEMAS} property.
      */
-    private final Map<String,String> schemas;
+    private final Map<String, String> schemas;
 
     /**
      * The GML version to be marshalled or unmarshalled, or {@code null} if 
unspecified.
@@ -198,7 +198,7 @@ public final class Context extends MarshalContext {
      *
      * @see #getObjectForID(Context, String)
      */
-    private final Map<String,Object> xmlidToObject;
+    private final Map<String, Object> xmlidToObject;
 
     /**
      * The identifiers used for marshalled objects in the current document.
@@ -206,7 +206,7 @@ public final class Context extends MarshalContext {
      * for a given object. The {@code gml:id} values to use are not 
necessarily the same as the values associated
      * to {@link IdentifierSpace#ID} if some identifiers were already used for 
other objects in the same XML document.
      */
-    private final Map<Object,String> objectToXmlid;
+    private final Map<Object, String> objectToXmlid;
 
     /**
      * The {@link #xmlidToObject} map for each document being unmarshalled.
@@ -223,7 +223,7 @@ public final class Context extends MarshalContext {
      * <p>Values of this map are the {@link #xmlidToObject} maps of the 
corresponding document.
      * See {@link #xmlidToObject} for a description of the meaning of those 
maps.</p>
      */
-    private final Map<Object, Map<String,Object>> documentToXmlids;
+    private final Map<Object, Map<String, Object>> documentToXmlids;
 
     /**
      * All identified objects associated to a global identifier (not {@code 
gml:id}).
@@ -285,11 +285,12 @@ public final class Context extends MarshalContext {
      * @param  converter        the converter in use.
      * @param  logFilter        the object to inform about warnings.
      */
+    @SuppressWarnings("LeakingThisInConstructor")
     public Context(int                       bitMasks,
                    final MarshallerPool      pool,
                    final Locale              locale,
                    final ZoneId              timezone,
-                   final Map<String,String>  schemas,
+                   final Map<String, String> schemas,
                    final Version             versionGML,
                    final Version             versionMetadata,
                    final ExternalLinkHandler linkHandler,
@@ -902,6 +903,8 @@ public final class Context extends MarshalContext {
 
     /**
      * Returns a string representation of this context for debugging purposes.
+     *
+     * @return debugging information.
      */
     @Override
     public String toString() {
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/WKTFormat.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/WKTFormat.java
index 365840cdc6..5ba36e940f 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/WKTFormat.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/WKTFormat.java
@@ -800,8 +800,9 @@ public class WKTFormat extends CompoundFormat<Object> {
 
     /**
      * Sets the URI to declare as the source of the WKT definitions. This 
information will be stored in
-     * {@link org.apache.sis.parameter.DefaultParameterValue#getSourceFile()} 
at WKT parsing time as a
-     * hint for resolving relative paths as absolute paths. This value has no 
effect at formatting time.
+     * {@link 
org.apache.sis.parameter.DefaultParameterValue#setSourceFile(URI)} at WKT 
parsing time as a
+     * hint for resolving relative paths found in {@code PARAMETERFILE[…]} 
elements.
+     * This value has no effect at formatting time.
      *
      * @param  document  URI to the file that contains the WKT definitions to 
parse, or {@code null} if none.
      *
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/DefaultParameterValue.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/DefaultParameterValue.java
index 7b5197913b..f4503627d7 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/DefaultParameterValue.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/DefaultParameterValue.java
@@ -21,6 +21,7 @@ import java.lang.reflect.Type;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.logging.Logger;
+import java.util.function.BiFunction;
 import java.io.Serializable;
 import java.io.File;
 import java.nio.file.Path;
@@ -52,6 +53,7 @@ import 
org.apache.sis.metadata.internal.shared.ImplementationHelper;
 import org.apache.sis.referencing.internal.Resources;
 import org.apache.sis.referencing.internal.shared.WKTUtilities;
 import org.apache.sis.referencing.internal.shared.WKTKeywords;
+import org.apache.sis.referencing.operation.transform.MathTransformBuilder;
 import org.apache.sis.math.DecimalFunctions;
 import org.apache.sis.math.NumberType;
 import org.apache.sis.measure.Units;
@@ -101,22 +103,25 @@ import org.apache.sis.util.logging.Logging;
  *     Class<T> valueClass = parameter.getDescriptor().getValueClass();
  *     }
  *
- * <h2>Absolute paths of value files</h2>
+ * <h2>Resolution of <abbr>URI</abbr> parameter values</h2>
  * Parameters that are too complex for being expressed as an {@code int[]}, 
{@code double[]} or {@code String} type
  * may be encoded in auxiliary files. It is the case, for example, of gridded 
data such as datum shift grids.
  * The name of an auxiliary file is given by {@link #valueFile()}, but often 
as a <em>relative</em> path.
  * The directory where that file is located depends on the operation using the 
parameter.
- * For example, datum shift grids used by coordinate transformations are 
searched in the
+ * For example, datum shift grid files used by coordinate transformations are 
searched in the
  * {@code $SIS_DATA/DatumChanges} directory, where {@code $SIS_DATA} is the 
value of the environment variable.
- * However, the latest approach requires that all potentially used auxiliary 
files are preexisting on the local machine.
- * This assumption may be applicable for parameters coming from a well-known 
registry such as EPSG, but cannot work
- * with arbitrary operations where the auxiliary files need to be transferred 
together with the parameter values.
- * For the latter case, an alternative is to consider the auxiliary files as 
relative to the GML document or WKT file
- * that provides the parameter values. For allowing users to resolve or 
download auxiliary files in that way,
- * a {@link #getSourceFile()} method is provided. Operations can then use 
{@link URI#resolve(URI)} for getting the
- * absolute path of an auxiliary file from the same server or directory than 
the GML or WKT file of parameter values.
+ * However, this approach requires that all potentially used auxiliary files 
are preexisting on the local machine.
+ * This assumption may be applicable for parameters coming from a well-known 
registry such as <abbr>EPSG</abbr>,
+ * but cannot work with arbitrary operations where the auxiliary files need to 
be transferred together with the parameter values.
+ * For the latter case, Apache <abbr>SIS</abbr> fallbacks on <abbr>URI</abbr> 
relative to the directory of the
+ * <abbr>JSON</abbr>, <abbr>GML</abbr> or <abbr>WKT</abbr> document where the 
parameter value appears.
+ * This resolution can be done only if the document directory is specified by 
the {@link #getSourceFile()} method.
  *
- * <h2>Instantiation</h2>
+ * <p><abbr>URI</abbr> parameter values are not resolved by this class, but by 
the operation which uses this parameter.
+ * For security reasons, an operation may reject <abbr>URI</abbr>s that are 
not in the expected directory.
+ * For controlling which <abbr>URI</abbr>s to accept, see {@link 
MathTransformBuilder#setAccessControl(BiFunction)}.</p>
+ *
+ * <h2>Instantiation of parameter values</h2>
  * A {@linkplain DefaultParameterDescriptor parameter descriptor} must be 
defined before parameter value can be created.
  * Descriptors are usually predefined (often hard-coded) by map projection or 
process providers. Given a descriptor,
  * the preferred way to create a parameter value is to invoke the {@link 
ParameterDescriptor#createValue()} method.
@@ -250,10 +255,14 @@ public class DefaultParameterValue<T> extends 
FormattableObject implements Param
     }
 
     /**
-     * Returns the <abbr>URI</abbr> of the <abbr>GML</abbr> document
-     * or <abbr>WKT</abbr> file from which the parameter values are read.
-     * This information allows to interpret {@link #valueFile()} as a path 
relative to the file that defined
-     * this parameter value. For example, the following snippet gets the file, 
then tries to make it absolute:
+     * Returns the <abbr>URI</abbr> of the <abbr>JSON</abbr>, <abbr>GML</abbr> 
or <abbr>WKT</abbr>
+     * document providing this parameter value. In the case of formats such as 
<abbr>GML</abbr>
+     * where parameter values can be declared in separated files referenced by 
{@code xlink:href},
+     * each parameter may have its own source <abbr>URI</abbr>.
+     *
+     * <p>This information can be used for resolving relative {@link 
#valueFile()} as a sibling
+     * (i.e., a file in the same directory) of the source file that defined 
this parameter value.
+     * For example, the following snippet gets the file, then tries to make it 
absolute:</p>
      *
      * {@snippet lang="java" :
      *     DefaultParameterValue<?> pv = ...;
@@ -261,7 +270,7 @@ public class DefaultParameterValue<T> extends 
FormattableObject implements Param
      *     file = pv.getSourceFile().map((base) -> 
base.resolve(file)).orElse(file);
      *     }
      *
-     * @return the <abbr>URI</abbr> of the document from which the parameter 
values are read.
+     * @return the <abbr>URI</abbr> of the document from which the parameter 
value has is read.
      *
      * @see #setSourceFile(URI)
      * @see Parameters#getSourceFile(ParameterDescriptor)
@@ -547,11 +556,12 @@ public class DefaultParameterValue<T> extends 
FormattableObject implements Param
      * The default implementation can convert the following value types:
      * {@link URI}, {@link URL}, {@link Path}, {@link File}.
      *
-     * <h4>Relative paths to absolute paths</h4>
-     * This parameter value is often a path relative to an unspecified 
directory. The base directory
-     * depends on the context. For example, it may be a directory where all 
datum grids are cached.
-     * Sometime, it is convenient to interpret the path as relative to the GML 
document or WKT file
-     * that defined this parameter value. For such resolution, see {@link 
#getSourceFile()}.
+     * <p>This parameter value is often a path relative to a context-dependent 
directory.
+     * For example, if this parameter specifies a datum shift grid file,
+     * then the returned value may be relative to the {@code 
$SIS_DATA/DatumChanges} directory.
+     * If the file is not found in that directory or if this parameter is not 
for a datum shift,
+     * then the returned value may be relative to the directory of the 
<abbr>JSON</abbr>, <abbr>GML</abbr>
+     * or <abbr>WKT</abbr> document where this parameter value appears.</p>
      *
      * @return the reference to a file containing parameter values.
      * @throws InvalidParameterTypeException if the value is not a reference 
to a file or a URI.
@@ -559,6 +569,8 @@ public class DefaultParameterValue<T> extends 
FormattableObject implements Param
      *
      * @see #getValue()
      * @see #setValue(Object)
+     * @see #getSourceFile()
+     * @see MathTransformBuilder#setAccessControl(BiFunction)
      */
     @Override
     public URI valueFile() throws IllegalStateException {
@@ -602,9 +614,11 @@ public class DefaultParameterValue<T> extends 
FormattableObject implements Param
     }
 
     /**
-     * Sets the URI of the GML document or WKT file from which this parameter 
value has been read.
-     * The given URI is a hint to be returned by {@link #getSourceFile()} for 
allowing callers to
-     * {@linkplain URI#resolve(URI) resolve} relative {@linkplain #valueFile() 
value files}.
+     * Sets the <abbr>URI</abbr> of the <abbr>JSON</abbr>, <abbr>GML</abbr> or 
<abbr>WKT</abbr>
+     * document providing this parameter value.
+     * This information can be used for resolving relative file returned by 
{@link #valueFile()}.
+     * Each parameter can declare its own source <abbr>URI</abbr> because some 
formats such as <abbr>GML</abbr>
+     * can declare parameter values in separated files referenced by {@code 
xlink:href}.
      *
      * @param document  URI of the document from which this parameter value 
has been read, or {@code null} if none.
      *
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/Parameters.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/Parameters.java
index b850532f36..9c6a0cbec1 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/Parameters.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/Parameters.java
@@ -517,10 +517,10 @@ public abstract class Parameters implements 
ParameterValueGroup, Cloneable, Prin
     }
 
     /**
-     * Returns the <abbr>URI</abbr> of the <abbr>GML</abbr> document
-     * or <abbr>WKT</abbr> file from which the parameter values are read.
+     * Returns the <abbr>URI</abbr> of the <abbr>JSON</abbr>, <abbr>GML</abbr> 
or <abbr>WKT</abbr>
+     * document providing the value of the specified parameter.
      * This information can be used together with {@code 
getValue(ParameterDescriptor<URI>)} for
-     * resolving a parameter value as a path relative to the GML or WKT file 
declaring the parameter.
+     * resolving a parameter value as a path relative to the document 
declaring the parameter.
      * Note that the source file is not necessarily the same for all 
parameters in a group, because a GML
      * document could define parameters in files referenced by different 
{@code xlink:href} attribute values.
      *
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/GridFile.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/GridFile.java
index f8bd80929a..5f8f588e17 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/GridFile.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/GridFile.java
@@ -17,17 +17,9 @@
 package org.apache.sis.referencing.operation.gridded;
 
 import java.net.URI;
-import java.io.BufferedReader;
-import java.io.InputStreamReader;
 import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.nio.channels.Channels;
-import java.nio.channels.ReadableByteChannel;
 import java.nio.file.Path;
-import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
-import java.nio.file.FileSystemNotFoundException;
-import java.util.Optional;
 import java.util.logging.Level;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.opengis.util.FactoryException;
@@ -36,9 +28,13 @@ import org.opengis.parameter.ParameterNotFoundException;
 import org.apache.sis.parameter.Parameters;
 import org.apache.sis.referencing.factory.FactoryDataException;
 import org.apache.sis.referencing.factory.MissingFactoryResourceException;
+import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
 import org.apache.sis.referencing.operation.provider.AbstractProvider;
+import org.apache.sis.referencing.operation.transform.MathTransformBuilder;
+import 
org.apache.sis.referencing.operation.transform.MathTransformProvider.Context;
 import org.apache.sis.referencing.internal.Resources;
 import org.apache.sis.system.DataDirectory;
+import org.apache.sis.system.DataURI;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.resources.Messages;
@@ -48,12 +44,15 @@ import org.apache.sis.util.resources.Messages;
  * Resolved path to a grid file. The starting point is the path specified by a 
parameter.
  * If that path is relative, then this class tries to resolve it in a 
directory specified
  * by the {@code SIS_DATA} environment variable. If the path cannot be 
resolved that way,
- * then this method check if it can be resolved relatively to the GML or WKT 
file containing
+ * then this class checks if the path can be resolved relatively to the 
document containing
  * the parameter.
  *
+ * <p>Instances of this class should be temporary.
+ * This is an helper class for loading data and discarded after the loading 
completed.</p>
+ *
  * @author  Martin Desruisseaux (Geomatys)
  */
-public final class GridFile {
+public final class GridFile extends DataURI {
     /**
      * Whether the tip about the location of datum shift files has been logged.
      * We log this tip only once, and only if we failed to load at least one 
grid.
@@ -67,28 +66,6 @@ public final class GridFile {
         return DataDirectory.DATUM_CHANGES;
     }
 
-    /**
-     * The URI specified in the parameter. This URI is usually relative to an 
unspecified directory.
-     *
-     * @see #resolved()
-     */
-    public final URI parameter;
-
-    /**
-     * The URI as an absolute path.
-     */
-    private URI resolved;
-
-    /**
-     * The base URI used for resolving the parameter, or {@code null} if none.
-     */
-    private URI base;
-
-    /**
-     * The resolved URI as a path, or {@code null} if not yet computed or not 
convertible.
-     */
-    private Path asPath;
-
     /**
      * Resolves the given parameter as an absolute URI, resolved in the {@code 
"$SIS_DATA/DatumChanges"} directory
      * if the URI is relative. If the URI cannot be resolved, a {@link 
MissingFactoryResourceException} is thrown.
@@ -96,117 +73,59 @@ public final class GridFile {
      * but cannot be constructed because an optional configuration is missing.
      * It is typically because the {@code SIS_DATA} environment variable has 
not been set.
      *
-     * @param  group  the group of parameters from which to get the URI.
-     * @param  param  identification of the parameter to fetch.
+     * @param  context  context of the transform to create, or {@code null}.
+     * @param  group    the group of parameters from which to get the URI.
+     * @param  param    identification of the parameter to fetch.
      * @throws ParameterNotFoundException if the specified parameter is not 
found in the given group.
      * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws InvalidGeodeticParameterException if access is denied.
      */
-    public GridFile(final Parameters group, final ParameterDescriptor<URI> 
param) throws MissingFactoryResourceException {
-        RuntimeException error = null;
-        parameter = group.getMandatoryValue(param);
-        if (parameter.isAbsolute()) {
-            resolved = parameter.normalize();
-        } else {
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    public GridFile(final Context context, final Parameters group, final 
ParameterDescriptor<URI> param)
+            throws FactoryException
+    {
+        super(group.getMandatoryValue(param));
+        /*
+         * First, try to resolve the parameter relatively to the 
"$SIS_DATA/DatumChanges" directory.
+         * That directory can be seen as a cache to be tried before to 
potentially download the data.
+         */
+        if (!tryResolve(localDirectory().getDirectoryAsURI()) || 
isFileMissing()) {
             /*
-             * First, try to resolve the parameter relative to the 
"$SIS_DATA/DatumChanges" directory.
-             * That directory can be seen as a cache to be tried before to 
download data that may be
-             * on the network.
+             * If the "$SIS_DATA/DatumChanges" directory cannot be used, 
assume a file in the same directory
+             * as the document that provided the parameter. Throw an exception 
if no resolution was possible,
+             * including with previous attempt. Do not throw an exception for 
file not found,
+             * because that check will be done when the file will be opened.
              */
-            base = localDirectory().getDirectoryAsURI();
-            if (base != null) try {
-                resolved = base.resolve(parameter).normalize();
-                asPath = Path.of(resolved);
-                if (Files.exists(asPath)) {
-                    return;
-                }
-            } catch (IllegalArgumentException | FileSystemNotFoundException e) 
{
-                error = e;
-            }
-            /*
-             * If the "$SIS_DATA/DatumChanges" directory cannot be used, check 
if we
-             * have another base URI that we could try. If not, we cannot 
continue.
-             */
-            final URI document = group.getSourceFile(param).orElse(null);
-            if (document == null) {
-                if (resolved != null) {
-                    return;             // NoSuchFileException will be thrown 
later by `newByteChannel()`.
-                }
+            if (!tryResolve(group.getSourceFile(param).orElse(null)) && 
resolved() == null) {
+                /*
+                 * If the URL cannot be resolved, the most important reason is 
because `SIS_DATA` was not set.
+                 * Try to provide an helpful error message. This is not about 
whether the file exists.
+                 */
                 final String message;
-                if (parameter.isOpaque()) {
-                    message = Errors.format(Errors.Keys.CanNotOpen_1, 
parameter);
+                if (DataDirectory.getenv() == null) {
+                    message = 
Messages.format(Messages.Keys.DataDirectoryNotSpecified_1, DataDirectory.ENV);
                 } else {
-                    final String env = DataDirectory.getenv();
-                    if (env == null) {
-                        message = 
Messages.format(Messages.Keys.DataDirectoryNotSpecified_1, DataDirectory.ENV);
-                    } else {
-                        message = 
Messages.format(Messages.Keys.DataDirectoryNotAccessible_2, DataDirectory.ENV, 
env);
-                    }
+                    message = Errors.format(Errors.Keys.CanNotOpen_1, 
parameter);
                 }
                 throw new MissingFactoryResourceException(message, error);
             }
-            /*
-             * Use the alternative base URI without checking if it exists.
-             * This check will be done when the file will be opened.
-             */
-            base = document;
-            resolved = document.resolve(parameter).normalize();
-        }
-        try {
-            asPath = Path.of(resolved);
-        } catch (IllegalArgumentException | FileSystemNotFoundException e) {
-            if (error == null) error = e;
-            else error.addSuppressed(e);
-            asPath = null;
         }
         if (error != null) {
             Logging.ignorableException(AbstractProvider.LOGGER, 
GridFile.class, "<init>", error);
         }
-    }
-
-    /**
-     * Returns the resolved <abbr>URI</abbr>.
-     *
-     * @see #parameter
-     */
-    public URI resolved() {
-        return resolved;
-    }
-
-    /**
-     * Returns the resolved <abbr>URI</abbr> as a path if possible.
-     * A use case for this method is grids to open as a {@link 
org.apache.sis.storage.DataStore}.
-     */
-    public Optional<Path> path() {
-        return Optional.ofNullable(asPath);
-    }
-
-    /**
-     * Creates a channel for reading bytes from the file at the path specified 
at construction time.
-     * This method tries to open using the file system before to open from the 
URL.
-     *
-     * @return a channel for reading bytes from the file.
-     * @throws IOException if the channel cannot be created.
-     */
-    public ReadableByteChannel newByteChannel() throws IOException {
-        if (asPath != null) {
-            return Files.newByteChannel(asPath);
-        } else {
-            return Channels.newChannel(resolved.toURL().openStream());
+        /*
+         * Verify authorization to read the file at the given URL. If there is 
no user-specified access control,
+         * the default is the verify that the URL is not outside the local 
data directory or the parent directory.
+         */
+        if (context instanceof MathTransformBuilder) {
+            final var builder = (MathTransformBuilder) context;
+            switch (builder.getAccessControl().apply(param, resolved())) {
+                case GRANTED: return;
+                case DENIED: isRelative = false; break;
+            }
         }
-    }
-
-    /**
-     * Creates a buffered reader for reading characters from the file at the 
path specified at construction time.
-     * This method tries to open using the file system before to open from the 
URL.
-     *
-     * @return a channel for reading bytes from the file.
-     * @throws IOException if the reader cannot be created.
-     */
-    public BufferedReader newBufferedReader() throws IOException {
-        if (asPath != null) {
-            return Files.newBufferedReader(asPath);
-        } else {
-            return new BufferedReader(new 
InputStreamReader(resolved.toURL().openStream()));
+        if (!isRelative) {
+            throw new InvalidGeodeticParameterException(accessDenied());
         }
     }
 
@@ -259,12 +178,4 @@ public final class GridFile {
             return new FactoryDataException(message, cause);
         }
     }
-
-    /**
-     * Returns a string representation of this path for debugging purposes.
-     */
-    @Override
-    public String toString() {
-        return String.valueOf(resolved);
-    }
 }
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/LoadedGrid.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/LoadedGrid.java
index f19d618c50..1a5eeaf7c8 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/LoadedGrid.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/gridded/LoadedGrid.java
@@ -487,15 +487,15 @@ public abstract class LoadedGrid<C extends Quantity<C>, T 
extends Quantity<T>> e
      * @see 
InterpolatedTransform#createGeodeticTransformation(MathTransformFactory, 
DatumShiftGrid)
      */
     public static MathTransform createGeodeticTransformation(final Class<? 
extends AbstractProvider> provider,
-            final MathTransformFactory factory, final LoadedGrid<Angle,Angle> 
grid) throws FactoryException
+            final MathTransformFactory factory, final LoadedGrid<Angle, Angle> 
grid) throws FactoryException
     {
         MathTransform global = 
InterpolatedTransform.createGeodeticTransformation(factory, grid);
-        final LoadedGrid<Angle,Angle>[] subgrids = grid.subgrids;
+        final LoadedGrid<Angle, Angle>[] subgrids = grid.subgrids;
         if (subgrids == null) {
             return global;
         }
         final Map<Envelope, MathTransform> specializations = 
JDK19.newLinkedHashMap(subgrids.length);
-        for (final LoadedGrid<Angle,Angle> sg : subgrids) try {
+        for (final LoadedGrid<Angle, Angle> sg : subgrids) try {
             final Envelope domain = sg.getDomainOfValidity(Units.DEGREE);
             final MathTransform st = createGeodeticTransformation(provider, 
factory, sg);
             if (specializations.putIfAbsent(domain, st) != null) {
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolation.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolation.java
index 5f32bd89c9..1b6b66c991 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolation.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolation.java
@@ -280,7 +280,7 @@ public final class FranceGeocentricInterpolation extends 
AbstractProvider {
     public MathTransform createMathTransform(final Context context) throws 
FactoryException {
         final Parameters pg = 
Parameters.castOrWrap(context.getCompletedParameters());
         final int dim = pg.getValue(Molodensky.DIMENSION);
-        final GridFile file = new GridFile(pg, FILE);
+        final var file = new GridFile(context, pg, FILE);
         final LoadedGrid<Angle, Length> grid;
         try {
             grid = getOrLoad(file, isRecognized(file) ? new double[] {TX, TY, 
TZ} : null, PRECISION);
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NADCON.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NADCON.java
index 39abed6781..75c39f6524 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NADCON.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NADCON.java
@@ -139,10 +139,10 @@ public final class NADCON extends AbstractProvider {
     @Override
     public MathTransform createMathTransform(final Context context) throws 
FactoryException {
         final Parameters pg = 
Parameters.castOrWrap(context.getCompletedParameters());
-        final GridFile latitudeShifts  = new GridFile(pg, LATITUDE);
-        final GridFile longitudeShifts = new GridFile(pg, LONGITUDE);
+        final var latitudeShifts  = new GridFile(context, pg, LATITUDE);
+        final var longitudeShifts = new GridFile(context, pg, LONGITUDE);
         try {
-            LoadedGrid<Angle,Angle> grid = getOrLoad(latitudeShifts, 
longitudeShifts);
+            LoadedGrid<Angle, Angle> grid = getOrLoad(latitudeShifts, 
longitudeShifts);
             return LoadedGrid.createGeodeticTransformation(NADCON.class, 
context.getFactory(), grid);
         } catch (NoSuchFileException e) {
             throw new MissingFactoryResourceException(e.getMessage(), e);
@@ -161,7 +161,7 @@ public final class NADCON extends AbstractProvider {
      * @param  longitudeShifts  relative or absolute path name of the grid 
file for longitude shifts.
      * @throws Exception if an error occurred while loading the grid.
      */
-    static LoadedGrid<Angle,Angle> getOrLoad(final GridFile latitudeShifts, 
final GridFile longitudeShifts)
+    static LoadedGrid<Angle, Angle> getOrLoad(final GridFile latitudeShifts, 
final GridFile longitudeShifts)
             throws Exception
     {
         return LoadedGrid.getOrLoad(latitudeShifts, longitudeShifts, () -> {
@@ -258,7 +258,7 @@ public final class NADCON extends AbstractProvider {
         /**
          * The grid created by {@link #readGrid(FloatBuffer, Loader, URI)}.
          */
-        LoadedGrid.Float<Angle,Angle> grid;
+        LoadedGrid.Float<Angle, Angle> grid;
 
         /**
          * Creates a new reader for the given channel. The file can be binary 
or ASCII.
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NTv2.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NTv2.java
index 9a15b6f4b5..18d8174b64 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NTv2.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/NTv2.java
@@ -140,8 +140,9 @@ public final class NTv2 extends AbstractProvider {
     static MathTransform createMathTransform(final Class<? extends 
AbstractProvider> provider,
             final Context context, final int version) throws FactoryException
     {
-        final GridFile file = new 
GridFile(Parameters.castOrWrap(context.getCompletedParameters()), FILE);
-        final LoadedGrid<Angle,Angle> grid;
+        final Parameters pg = 
Parameters.castOrWrap(context.getCompletedParameters());
+        final var file = new GridFile(context, pg, FILE);
+        final LoadedGrid<Angle, Angle> grid;
         try {
             grid = getOrLoad(provider, file, version);
         } catch (FactoryException e) {
@@ -164,14 +165,14 @@ public final class NTv2 extends AbstractProvider {
      *
      * @see GridLoader#canNotLoad(String, URI, Exception)
      */
-    static LoadedGrid<Angle,Angle> getOrLoad(final Class<? extends 
AbstractProvider> provider,
+    static LoadedGrid<Angle, Angle> getOrLoad(final Class<? extends 
AbstractProvider> provider,
             final GridFile file, final int version) throws Exception
     {
         return LoadedGrid.getOrLoad(file, null, () -> {
             final LoadedGrid<?,?> grid;
             try (ReadableByteChannel in = file.newByteChannel()) {
                 file.startLoading(provider);
-                final Loader loader = new Loader(in, file, version);
+                final var loader = new Loader(in, file, version);
                 grid = loader.readAllGrids();
                 loader.report(provider);
             }
@@ -437,9 +438,9 @@ public final class NTv2 extends AbstractProvider {
          * them in a child-parent relationship. The result is a tree with a 
single root containing
          * sub-grids (if any) as children.
          */
-        final LoadedGrid<Angle,Angle> readAllGrids() throws IOException, 
FactoryException, NoninvertibleTransformException {
-            final Map<String,      LoadedGrid<Angle,Angle>>  grids    = 
JDK19.newHashMap(numGrids);
-            final Map<String, List<LoadedGrid<Angle,Angle>>> children = new 
LinkedHashMap<>();   // Should have few entries.
+        final LoadedGrid<Angle, Angle> readAllGrids() throws IOException, 
FactoryException, NoninvertibleTransformException {
+            final Map<String,      LoadedGrid<Angle, Angle>>  grids    = 
JDK19.newHashMap(numGrids);
+            final Map<String, List<LoadedGrid<Angle, Angle>>> children = new 
LinkedHashMap<>();   // Should have few entries.
             while (grids.size() < numGrids) {
                 readGrid(grids, children);
             }
@@ -451,10 +452,10 @@ public final class NTv2 extends AbstractProvider {
              *        the grids in cycles will be lost. This is because we 
need a grid without parent for getting the
              *        graph added in the roots list. There is currently no 
mechanism for detecting those problems.
              */
-            final List<LoadedGrid<Angle,Angle>> roots = new ArrayList<>();
-            for (final Map.Entry<String, List<LoadedGrid<Angle,Angle>>> entry 
: children.entrySet()) {
-                final LoadedGrid<Angle,Angle> parent = 
grids.get(entry.getKey());
-                final List<LoadedGrid<Angle,Angle>> subgrids = 
entry.getValue();
+            final List<LoadedGrid<Angle, Angle>> roots = new ArrayList<>();
+            for (final Map.Entry<String, List<LoadedGrid<Angle, Angle>>> entry 
: children.entrySet()) {
+                final LoadedGrid<Angle, Angle> parent = 
grids.get(entry.getKey());
+                final List<LoadedGrid<Angle, Angle>> subgrids = 
entry.getValue();
                 if (parent != null) {
                     /*
                      * Verify that the children does not declare themselves as 
their parent.
@@ -494,8 +495,8 @@ public final class NTv2 extends AbstractProvider {
          * @param  addTo     the map where to add the grid with the grid name 
as the key.
          * @param  children  the map where to add children with the parent 
name as the key.
          */
-        private void readGrid(final Map<String, LoadedGrid<Angle,Angle>> addTo,
-                final Map<String, List<LoadedGrid<Angle,Angle>>> children)
+        private void readGrid(final Map<String, LoadedGrid<Angle, Angle>> 
addTo,
+                final Map<String, List<LoadedGrid<Angle, Angle>>> children)
                 throws IOException, FactoryException, 
NoninvertibleTransformException
         {
             if (isV2) {
@@ -545,9 +546,9 @@ public final class NTv2 extends AbstractProvider {
              * will be handled by grid.coordinateToGrid MathTransform and its 
inverse.
              */
             final double size = Math.max(dx, dy);
-            final LoadedGrid<Angle,Angle> grid;
+            final LoadedGrid<Angle, Angle> grid;
             if (isV2) {
-                final LoadedGrid.Float<Angle,Angle> data;
+                final LoadedGrid.Float<Angle, Angle> data;
                 data = new LoadedGrid.Float<>(2, unit, unit, true,
                         -xmin, ymin, -dx, dy, width, height, PARAMETERS, file);
                 @SuppressWarnings("MismatchedReadAndWriteOfArray") final 
float[] tx = data.offsets[0];
@@ -567,7 +568,7 @@ public final class NTv2 extends AbstractProvider {
                 /*
                  * NTv1: same as NTv2 but using double precision and without 
accuracy information.
                  */
-                final LoadedGrid.Double<Angle,Angle> data;
+                final LoadedGrid.Double<Angle, Angle> data;
                 grid = data = new LoadedGrid.Double<>(2, unit, unit, true,
                         -xmin, ymin, -dx, dy, width, height, PARAMETERS, file);
                 @SuppressWarnings("MismatchedReadAndWriteOfArray") final 
double[] tx = data.offsets[0];
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
index b6b1435956..8de5698b8a 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
@@ -16,12 +16,16 @@
  */
 package org.apache.sis.referencing.operation.transform;
 
+import java.net.URI;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.function.BiFunction;
+import org.opengis.parameter.ParameterDescriptor;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.MathTransformFactory;
 import org.opengis.referencing.operation.OperationMethod;
 import org.apache.sis.referencing.IdentifiedObjects;
+import org.apache.sis.io.Authorization;
 import org.apache.sis.util.Classes;
 import org.apache.sis.util.internal.shared.Strings;
 
@@ -35,7 +39,7 @@ import org.apache.sis.util.internal.shared.Strings;
  * Then, the transform is created by a call to {@link #create()}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.5
+ * @version 1.7
  * @since   1.5
  */
 public abstract class MathTransformBuilder implements MathTransform.Builder {
@@ -44,6 +48,12 @@ public abstract class MathTransformBuilder implements 
MathTransform.Builder {
      */
     protected final MathTransformFactory factory;
 
+    /**
+     * A function which determines whether the <abbr>URI</abbr> specified in a 
parameter can be opened.
+     * The default access control returns {@link Authorization#DEFAULT}.
+     */
+    private BiFunction<ParameterDescriptor<URI>, URI, Authorization> 
accessControl;
+
     /**
      * The provider that created the parameterized {@link MathTransform} 
instance, or {@code null}
      * if this information does not apply. This is initially set to the 
operation method specified
@@ -63,6 +73,11 @@ public abstract class MathTransformBuilder implements 
MathTransform.Builder {
      */
     protected MathTransformBuilder(final MathTransformFactory factory) {
         this.factory = Objects.requireNonNull(factory);
+        accessControl = (param, file) -> {
+            Objects.requireNonNull(param);
+            Objects.requireNonNull(file);
+            return Authorization.DEFAULT;
+        };
     }
 
     /**
@@ -78,6 +93,40 @@ public abstract class MathTransformBuilder implements 
MathTransform.Builder {
         return Optional.ofNullable(provider);
     }
 
+    /**
+     * Returns a function which determines whether the <abbr>URI</abbr> 
specified in a parameter can be opened.
+     * The function will receive the following arguments:
+     *
+     * <ol>
+     *   <li>a description of the <abbr>URI</abbr> parameter,</li>
+     *   <li>the actual <abbr>URI</abbr> parameter value.</li>
+     * </ol>
+     *
+     * The default access control is a function returning {@link 
Authorization#DEFAULT}.
+     * The default authorization grants access to files in the {@code 
$SIS_DATA/DatumChanges}
+     * directory for parameters that are datum shift grid files, and to files 
in the same directory as the
+     * <abbr>JSON</abbr>, <abbr>GML</abbr> or <abbr>WKT</abbr> document where 
the parameter value appears.
+     *
+     * @return a function deciding whether the <abbr>URI</abbr> can be opened.
+     *
+     * @since 1.7
+     */
+    public BiFunction<ParameterDescriptor<URI>, URI, Authorization> 
getAccessControl() {
+        return accessControl;
+    }
+
+    /**
+     * Sets a function which determines whether the <abbr>URI</abbr> specified 
in a parameter can be opened.
+     * See {@link #getAccessControl()} for more information.
+     *
+     * @param  ac  function telling whether the <abbr>URI</abbr> can be opened.
+     *
+     * @since 1.7
+     */
+    public void setAccessControl(BiFunction<ParameterDescriptor<URI>, URI, 
Authorization> ac) {
+        accessControl = Objects.requireNonNull(ac);
+    }
+
     /**
      * Eventually replaces the given transform by a unique instance. The 
replacement is done
      * only if the {@linkplain #factory} is an instance of {@link 
DefaultMathTransformFactory}
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/xml/bind/referencing/CC_OperationMethod.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/xml/bind/referencing/CC_OperationMethod.java
index 9156a6f2ba..535139baaf 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/xml/bind/referencing/CC_OperationMethod.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/xml/bind/referencing/CC_OperationMethod.java
@@ -229,8 +229,8 @@ public final class CC_OperationMethod extends 
PropertyType<CC_OperationMethod, O
                     }
                     p = target;
                 } else if (p instanceof ParameterValueGroup) {
-                    final ParameterValueGroup source = (ParameterValueGroup) p;
-                    final ParameterValueGroup target = new 
DefaultParameterValueGroup((ParameterDescriptorGroup) replacement);
+                    final var source = (ParameterValueGroup) p;
+                    final var target = new 
DefaultParameterValueGroup((ParameterDescriptorGroup) replacement);
                     final Collection<GeneralParameterValue> values = 
source.values();
                     store(values.toArray(GeneralParameterValue[]::new), 
target.values(), replacements);
                     p = target;
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/io/wkt/ElementTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/io/wkt/ElementTest.java
index df20596fca..af5f2c3706 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/io/wkt/ElementTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/io/wkt/ElementTest.java
@@ -35,6 +35,7 @@ import org.apache.sis.test.TestCase;
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
+@SuppressWarnings("exports")
 public final class ElementTest extends TestCase {
     /**
      * Creates a new test case.
@@ -61,7 +62,7 @@ public final class ElementTest extends TestCase {
      * Parses the given text and ensures that {@link ParsePosition} index is 
set at to the end of string.
      */
     private Element parse(final String text) throws ParseException {
-        final ParsePosition position = new ParsePosition(0);
+        final var position = new ParsePosition(0);
         final Element element;
         try {
             element = new Element(parser, text, position);
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/gridded/GridFileTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/gridded/GridFileTest.java
new file mode 100644
index 0000000000..6bd47e14c3
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/gridded/GridFileTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.sis.referencing.operation.gridded;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Path;
+import org.opengis.util.FactoryException;
+import org.opengis.parameter.ParameterDescriptor;
+import org.opengis.parameter.ParameterValueGroup;
+import org.opengis.parameter.GeneralParameterValue;
+import org.apache.sis.parameter.Parameters;
+import org.apache.sis.parameter.ParameterBuilder;
+import org.apache.sis.parameter.DefaultParameterValue;
+import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.apache.sis.test.Assertions.assertMessageContains;
+import org.apache.sis.test.TestCase;
+
+
+/**
+ * Tests {@link GridFile}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+public final class GridFileTest extends TestCase {
+    /**
+     * Operation parameter descriptor for a dummy file
+     */
+    private final ParameterDescriptor<URI> param;
+
+    /**
+     * Dummy operation parameter.
+     */
+    private final Parameters group;
+
+    /**
+     * Creates a new test case.
+     */
+    public GridFileTest() {
+        final var builder = new ParameterBuilder();
+        param = builder.addName("GridFile").create(URI.class, null);
+        group = Parameters.castOrWrap(builder.addName("Test 
parameteters").createGroup(param).createValue());
+    }
+
+    /**
+     * Creates the grid file with the current parameter values.
+     */
+    private GridFile newGridFile() throws FactoryException {
+        return new GridFile(null, group, param);
+    }
+
+    /**
+     * Tests construction with a file in the local directory.
+     *
+     * @throws URISyntaxException if an error occurred during URI construction.
+     * @throws FactoryException if the construction failed.
+     */
+    @Test
+    public void testLocalDirectory() throws URISyntaxException, 
FactoryException {
+        final URI file = new URI("file:///tmp/test/dummy.txt");
+        group.getOrCreate(param).setValue(file);
+        assertMessageContains(
+                assertThrows(InvalidGeodeticParameterException.class, () -> 
newGridFile()),
+                file.getPath());
+        /*
+         * Test again, but replacing the full path by a path local to a dummy 
directory.
+         * The access should no longer be denied.
+         */
+        makeParameterRelativeToSourceFile(group);
+        final GridFile grid = newGridFile();
+        assertEquals(new URI("dummy.txt"), grid.parameter);
+        assertEquals(Path.of(file), grid.path().orElseThrow());
+    }
+
+    /**
+     * Replaces absolute <abbr>URI</abbr> by paths relative to a dummy 
document.
+     * This change is needed for avoiding "access denied" during test 
execution,
+     * because {@link GridFile} accepts to open only grid file from the same 
host
+     * as the <abbr>JSON</abbr>, <abbr>GML</abbr> or <abbr>WKT</abbr> document.
+     *
+     * @param  group  the group of parameters to edit.
+     * @throws URISyntaxException if an error occurred during URI construction.
+     */
+    public static void makeParameterRelativeToSourceFile(final 
ParameterValueGroup group) throws URISyntaxException {
+        for (GeneralParameterValue param : group.values()) {
+            final String name = param.getDescriptor().getName().getCode();
+            final var dp = assertInstanceOf(DefaultParameterValue.class, 
param, name);
+            if (dp.getDescriptor().getValueClass() == URI.class) {
+                final URI file = dp.valueFile();
+                if (file.isAbsolute()) {
+                    assertTrue(dp.getSourceFile().isEmpty(), name);
+                    dp.setSourceFile(file.resolve("ImaginaryDocument.xml"));
+                    URI parent = Path.of(file).getParent().toUri();
+                    dp.setValue(parent.relativize(file));
+                }
+            }
+        }
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolationTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolationTest.java
index 889d460104..2bee566881 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolationTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/FranceGeocentricInterpolationTest.java
@@ -29,7 +29,6 @@ import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.operation.gridded.GridFile;
 import org.apache.sis.referencing.operation.gridded.LoadedGrid;
 import org.apache.sis.referencing.operation.gridded.CompressedGrid;
-import org.apache.sis.referencing.factory.MissingFactoryResourceException;
 import org.apache.sis.parameter.Parameters;
 
 // Test dependencies
@@ -37,6 +36,7 @@ import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.*;
 import org.apache.sis.test.TestCase;
 import org.apache.sis.test.TestStep;
+import org.apache.sis.referencing.operation.gridded.GridFileTest;
 
 
 /**
@@ -102,12 +102,14 @@ public final class FranceGeocentricInterpolationTest 
extends TestCase {
      *
      * @param  file  the grid file <abbr>URI</abbr>.
      * @return an object representing the grid file at the given URI.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws URISyntaxException if the URL to the test file is not valid.
+     * @throws FactoryException if the path cannot be resolved.
      */
-    private static GridFile newGridFile(final URI file) throws 
MissingFactoryResourceException {
+    private static GridFile newGridFile(final URI file) throws 
URISyntaxException, FactoryException {
         Parameters pg = 
Parameters.castOrWrap(FranceGeocentricInterpolation.PARAMETERS.createValue());
         pg.getOrCreate(FranceGeocentricInterpolation.FILE).setValue(file);
-        return new GridFile(pg, FranceGeocentricInterpolation.FILE);
+        GridFileTest.makeParameterRelativeToSourceFile(pg);
+        return new GridFile(null, pg, FranceGeocentricInterpolation.FILE);
     }
 
     /**
@@ -116,10 +118,10 @@ public final class FranceGeocentricInterpolationTest 
extends TestCase {
      * @param  filename  filename of the grid to load.
      * @return an object representing the grid file for the specified resource.
      * @throws URISyntaxException if the URL to the test file is not valid.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws FactoryException if the path cannot be resolved.
      */
     private static GridFile getResource(final String filename)
-            throws URISyntaxException, MissingFactoryResourceException
+            throws URISyntaxException, FactoryException
     {
         URL file = 
FranceGeocentricInterpolationTest.class.getResource(filename);
         assertNotNull(file, filename);
@@ -132,12 +134,10 @@ public final class FranceGeocentricInterpolationTest 
extends TestCase {
      *
      * @param  resolved  the <abbr>URI</abbr> to test.
      * @throws URISyntaxException if the URL to the test file is not valid.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws FactoryException if the path cannot be resolved.
      * @return result of {@code FranceGeocentricInterpolation.isRecognized(…)}.
      */
-    private static boolean isRecognized(final String resolved)
-            throws URISyntaxException, MissingFactoryResourceException
-    {
+    private static boolean isRecognized(final String resolved) throws 
URISyntaxException, FactoryException {
         return FranceGeocentricInterpolation.isRecognized(newGridFile(new 
URI(resolved)));
     }
 
@@ -145,10 +145,10 @@ public final class FranceGeocentricInterpolationTest 
extends TestCase {
      * Tests {@link FranceGeocentricInterpolation#isRecognized(URI)}.
      *
      * @throws URISyntaxException if the URL to the test file is not valid.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws FactoryException if the path cannot be resolved.
      */
     @Test
-    public void testIsRecognized() throws URISyntaxException, 
MissingFactoryResourceException {
+    public void testIsRecognized() throws URISyntaxException, FactoryException 
{
         assertTrue (isRecognized("GR3DF97A.txt"));
         assertTrue (isRecognized("gr3df"));
         assertFalse(isRecognized("gr3d"));
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/GeocentricTranslationTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/GeocentricTranslationTest.java
index 341ed42f68..b1cde929ac 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/GeocentricTranslationTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/GeocentricTranslationTest.java
@@ -142,7 +142,7 @@ public final class GeocentricTranslationTest extends 
MathTransformTestCase {
         if (method instanceof GeocentricAffineBetweenGeographic) {
             setEllipsoids(values, CommonCRS.WGS84.ellipsoid(), 
CommonCRS.ED50.ellipsoid());
         }
-        transform = 
method.createMathTransform(DefaultMathTransformFactory.provider(), values);
+        transform = method.createMathTransform(null, values);
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NADCONTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NADCONTest.java
index 5747469378..91cc9ab133 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NADCONTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NADCONTest.java
@@ -27,12 +27,12 @@ import java.nio.file.Files;
 import javax.measure.quantity.Angle;
 import org.opengis.geometry.Envelope;
 import org.opengis.parameter.ParameterDescriptor;
+import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.operation.gridded.GridFile;
 import org.apache.sis.referencing.operation.gridded.GridLoader;
 import org.apache.sis.referencing.operation.gridded.LoadedGrid;
 import org.apache.sis.referencing.operation.matrix.Matrix3;
-import org.apache.sis.referencing.factory.MissingFactoryResourceException;
 import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.geometry.Envelopes;
 import org.apache.sis.measure.Units;
@@ -42,6 +42,7 @@ import org.apache.sis.parameter.Parameters;
 import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.*;
 import org.apache.sis.test.TestCase;
+import org.apache.sis.referencing.operation.gridded.GridFileTest;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import static org.opengis.test.Assertions.assertMatrixEquals;
@@ -109,14 +110,16 @@ public final class NADCONTest extends TestCase {
      * @param  file       the grid file <abbr>URI</abbr>.
      * @param  parameter  {@link NADCON#LONGITUDE} or {@link NADCON#LATITUDE}.
      * @return an object representing the grid file at the given URI.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws URISyntaxException if the URL to the test file is not valid.
+     * @throws FactoryException if the path cannot be resolved.
      */
-    public static GridFile newGridFile(final URI file, final 
ParameterDescriptor<URI> parameter)
-            throws MissingFactoryResourceException
+    public static GridFile newGridFile(URI file, ParameterDescriptor<URI> 
parameter)
+            throws URISyntaxException, FactoryException
     {
         Parameters pg = Parameters.castOrWrap(NADCON.PARAMETERS.createValue());
         pg.getOrCreate(parameter).setValue(file);
-        return new GridFile(pg, parameter);
+        GridFileTest.makeParameterRelativeToSourceFile(pg);
+        return new GridFile(null, pg, parameter);
     }
 
     /**
@@ -126,10 +129,10 @@ public final class NADCONTest extends TestCase {
      * @param  parameter  {@link NADCON#LONGITUDE} or {@link NADCON#LATITUDE}.
      * @return an object representing the grid file for the specified resource.
      * @throws URISyntaxException if the URL to the test file is not valid.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws FactoryException if the path cannot be resolved.
      */
     private static GridFile getResource(final String filename, final 
ParameterDescriptor<URI> parameter)
-            throws URISyntaxException, MissingFactoryResourceException
+            throws URISyntaxException, FactoryException
     {
         URL file = NADCONTest.class.getResource(filename);
         assertNotNull(file, filename);
@@ -175,7 +178,7 @@ public final class NADCONTest extends TestCase {
             final double xmin, final double xmax, final double ymin, final 
double ymax)
             throws Exception
     {
-        final LoadedGrid<Angle,Angle> grid = NADCON.getOrLoad(latitudeShifts, 
longitudeShifts);
+        final LoadedGrid<Angle, Angle> grid = NADCON.getOrLoad(latitudeShifts, 
longitudeShifts);
         assertInstanceOf(LoadedGrid.Float.class, grid, "Should not be 
compressed.");
         assertEquals(Units.DEGREE, grid.getCoordinateUnit());
         assertEquals(Units.DEGREE, grid.getTranslationUnit());
@@ -262,7 +265,7 @@ public final class NADCONTest extends TestCase {
      * @throws TransformException if an error occurred while computing the 
envelope.
      * @throws IOException if an error occurred while writing the test file.
      */
-    public static void writeSubGrid(final LoadedGrid<Angle,Angle> grid, final 
Path file, final int dim,
+    public static void writeSubGrid(final LoadedGrid<Angle, Angle> grid, final 
Path file, final int dim,
             final int gridX, final int gridY, final int nx, final int ny) 
throws IOException, TransformException
     {
         Envelope envelope = new Envelope2D(null, gridX, gridY, nx - 1, ny - 1);
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NTv2Test.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NTv2Test.java
index e07e471260..550f4dce9f 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NTv2Test.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/NTv2Test.java
@@ -29,13 +29,13 @@ import java.nio.channels.WritableByteChannel;
 import java.nio.charset.StandardCharsets;
 import javax.measure.quantity.Angle;
 import org.opengis.geometry.Envelope;
+import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.internal.shared.Formulas;
 import org.apache.sis.referencing.operation.matrix.Matrix3;
 import org.apache.sis.referencing.operation.gridded.GridFile;
 import org.apache.sis.referencing.operation.gridded.GridGroup;
 import org.apache.sis.referencing.operation.gridded.LoadedGrid;
-import org.apache.sis.referencing.factory.MissingFactoryResourceException;
 import static 
org.apache.sis.referencing.operation.gridded.GridLoader.DEGREES_TO_SECONDS;
 import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.geometry.Envelopes;
@@ -47,6 +47,7 @@ import org.apache.sis.system.DataDirectory;
 import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.*;
 import org.apache.sis.test.TestCase;
+import org.apache.sis.referencing.operation.gridded.GridFileTest;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import static org.opengis.test.Assertions.assertMatrixEquals;
@@ -91,12 +92,14 @@ public final class NTv2Test extends TestCase {
      *
      * @param  file  the grid file <abbr>URI</abbr>.
      * @return an object representing the grid file at the given URI.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws URISyntaxException if the URL to the test file is not valid.
+     * @throws FactoryException if the path cannot be resolved.
      */
-    public static GridFile newGridFile(final URI file) throws 
MissingFactoryResourceException {
+    public static GridFile newGridFile(final URI file) throws 
URISyntaxException, FactoryException {
         Parameters pg = Parameters.castOrWrap(NTv2.PARAMETERS.createValue());
         pg.getOrCreate(NTv2.FILE).setValue(file);
-        return new GridFile(pg, NTv2.FILE);
+        GridFileTest.makeParameterRelativeToSourceFile(pg);
+        return new GridFile(null, pg, NTv2.FILE);
     }
 
     /**
@@ -105,11 +108,9 @@ public final class NTv2Test extends TestCase {
      * @param  filename  filename of the grid to load.
      * @return an object representing the grid file for the specified resource.
      * @throws URISyntaxException if the URL to the test file is not valid.
-     * @throws MissingFactoryResourceException if the path cannot be resolved.
+     * @throws FactoryException if the path cannot be resolved.
      */
-    private static GridFile getResource(final String filename)
-            throws URISyntaxException, MissingFactoryResourceException
-    {
+    private static GridFile getResource(final String filename) throws 
URISyntaxException, FactoryException {
         URL file = NTv2Test.class.getResource(filename);
         assertNotNull(file, filename);
         return newGridFile(file.toURI());
@@ -218,7 +219,7 @@ public final class NTv2Test extends TestCase {
         assumeDataExists(DataDirectory.DATUM_CHANGES, MULTIGRID_TEST_FILE);
         final Parameters pg = 
Parameters.castOrWrap(NTv2.PARAMETERS.createValue());
         pg.getOrCreate(NTv2.FILE).setValue(new URI(MULTIGRID_TEST_FILE));
-        final GridFile file = new GridFile(pg, NTv2.FILE);
+        final var file = new GridFile(null, pg, NTv2.FILE);
 
         final LoadedGrid<Angle, Angle> grid = NTv2.getOrLoad(NTv2.class, file, 
2);
         assertInstanceOf(GridGroup.class, grid, "Should contain many grids.");
@@ -318,7 +319,7 @@ public final class NTv2Test extends TestCase {
      * @throws TransformException if an error occurred while computing the 
envelope.
      * @throws IOException if an error occurred while writing the test file.
      */
-    public static void writeSubGrid(final LoadedGrid<Angle,Angle> grid, final 
Path out,
+    public static void writeSubGrid(final LoadedGrid<Angle, Angle> grid, final 
Path out,
             final int gridX, final int gridY, final int nx, final int ny) 
throws IOException, TransformException
     {
         Envelope envelope = new Envelope2D(null, gridX, gridY, nx - 1, ny - 1);
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/PositionVector7ParamTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/PositionVector7ParamTest.java
index ef4b056a31..8949d4b75c 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/PositionVector7ParamTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/provider/PositionVector7ParamTest.java
@@ -24,7 +24,6 @@ import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.CommonCRS;
 import org.apache.sis.referencing.internal.shared.Formulas;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
-import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory;
 
 // Test dependencies
 import org.junit.jupiter.api.Test;
@@ -95,7 +94,7 @@ public final class PositionVector7ParamTest extends 
MathTransformTestCase {
         if (method instanceof GeocentricAffineBetweenGeographic) {
             GeocentricTranslationTest.setEllipsoids(values, 
CommonCRS.WGS72.ellipsoid(), CommonCRS.WGS84.ellipsoid());
         }
-        return 
method.createMathTransform(DefaultMathTransformFactory.provider(), values);
+        return method.createMathTransform(null, values);
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedGeocentricTransformTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedGeocentricTransformTest.java
index 20efd27cf5..1027d12358 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedGeocentricTransformTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedGeocentricTransformTest.java
@@ -17,15 +17,14 @@
 package org.apache.sis.referencing.operation.transform;
 
 import java.net.URL;
-import org.opengis.util.FactoryException;
 import org.opengis.parameter.ParameterValueGroup;
 import org.opengis.referencing.datum.Ellipsoid;
-import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.CommonCRS;
 import 
org.apache.sis.referencing.operation.provider.FranceGeocentricInterpolation;
 
 // Test dependencies
 import org.junit.jupiter.api.Test;
+import org.apache.sis.referencing.operation.gridded.GridFileTest;
 import 
org.apache.sis.referencing.operation.provider.FranceGeocentricInterpolationTest;
 import org.apache.sis.referencing.datum.HardCodedDatum;
 
@@ -47,16 +46,16 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
      * Creates the <q>France geocentric interpolation</q> transform,
      * including the normalization and denormalization parts.
      *
-     * @throws FactoryException if an error occurred while loading the grid.
+     * @throws Exception if an error occurred while loading the grid.
      */
-    void createGeodeticTransformation() throws FactoryException {
+    private void createGeodeticTransformation() throws Exception {
         createGeodeticTransformation(new FranceGeocentricInterpolation());
     }
 
     /**
      * Creates the transform using the given provider.
      */
-    final void createGeodeticTransformation(final 
FranceGeocentricInterpolation provider) throws FactoryException {
+    private void createGeodeticTransformation(final 
FranceGeocentricInterpolation provider) throws Exception {
         final URL file = 
FranceGeocentricInterpolationTest.class.getResource(FranceGeocentricInterpolationTest.TEST_FILE);
         final Ellipsoid source = HardCodedDatum.NTF.getEllipsoid();     // 
Clarke 1880 (IGN)
         final Ellipsoid target = CommonCRS.ETRS89.ellipsoid();          // GRS 
1980 ellipsoid
@@ -66,7 +65,8 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
         values.parameter("tgt_semi_major").setValue(target.getSemiMajorAxis());
         values.parameter("tgt_semi_minor").setValue(target.getSemiMinorAxis());
         values.parameter("Geocentric translation file").setValue(file);    // 
Automatic conversion from URL to Path.
-        transform = 
provider.createMathTransform(DefaultMathTransformFactory.provider(), values);
+        GridFileTest.makeParameterRelativeToSourceFile(values);
+        transform = provider.createMathTransform(null, values);
         tolerance = FranceGeocentricInterpolationTest.ANGULAR_TOLERANCE;
     }
 
@@ -75,11 +75,10 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
      * We call this transformation "forward" because it uses the grid values 
directly,
      * without doing first an approximation followed by an iteration.
      *
-     * @throws FactoryException if an error occurred while loading the grid.
-     * @throws TransformException if an error occurred while transforming the 
coordinate.
+     * @throws Exception if an error occurred while loading the grid or 
transforming the coordinate.
      */
     @Test
-    public void testForwardTransform() throws FactoryException, 
TransformException {
+    public void testForwardTransform() throws Exception {
         createGeodeticTransformation();   // Create the inverse of the 
transform we are interested in.
         transform = transform.inverse();
         isInverseTransformSupported = false;
@@ -97,11 +96,10 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
     /**
      * Tests transformation of sample point from NTF to RGF93.
      *
-     * @throws FactoryException if an error occurred while loading the grid.
-     * @throws TransformException if an error occurred while transforming the 
coordinate.
+     * @throws Exception if an error occurred while loading the grid or 
transforming the coordinate.
      */
     @Test
-    public void testInverseTransform() throws FactoryException, 
TransformException {
+    public void testInverseTransform() throws Exception {
         createGeodeticTransformation();
         isInverseTransformSupported = false;
         verifyTransform(FranceGeocentricInterpolationTest.samplePoint(1),
@@ -113,11 +111,10 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
      * Tests the derivatives at the sample point. This method compares the 
derivatives computed by
      * the transform with an estimation of derivatives computed by the finite 
differences method.
      *
-     * @throws FactoryException if an error occurred while loading the grid.
-     * @throws TransformException if an error occurred while transforming the 
coordinate.
+     * @throws Exception if an error occurred while loading the grid or 
transforming the coordinate.
      */
     @Test
-    public void testForwardDerivative() throws FactoryException, 
TransformException {
+    public void testForwardDerivative() throws Exception {
         createGeodeticTransformation();
         transform = transform.inverse();
         final double delta = (100.0 / 60) / 1852;           // Approximately 
100 metres.
@@ -130,11 +127,10 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
      * Tests the derivatives at the sample point. This method compares the 
derivatives computed by
      * the transform with an estimation of derivatives computed by the finite 
differences method.
      *
-     * @throws FactoryException if an error occurred while loading the grid.
-     * @throws TransformException if an error occurred while transforming the 
coordinate.
+     * @throws Exception if an error occurred while loading the grid or 
transforming the coordinate.
      */
     @Test
-    public void testInverseDerivative() throws FactoryException, 
TransformException {
+    public void testInverseDerivative() throws Exception {
         createGeodeticTransformation();
         final double delta = (100.0 / 60) / 1852;           // Approximately 
100 metres.
         derivativeDeltas = new double[] {delta, delta};
@@ -146,11 +142,10 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
      * Tests the Well Known Text (version 1) formatting.
      * The result is what we show to users, but may quite different than what 
SIS has in memory.
      *
-     * @throws FactoryException if an error occurred while creating a 
transform.
-     * @throws TransformException should never happen.
+     * @throws Exception if an error occurred while loading the grid or 
transforming the coordinate.
      */
     @Test
-    public void testWKT() throws FactoryException, TransformException {
+    public void testWKT() throws Exception {
         createGeodeticTransformation();
         transform = transform.inverse();
         assertWktEqualsRegex("(?m)\\Q" +
@@ -180,11 +175,10 @@ public final class InterpolatedGeocentricTransformTest 
extends MathTransformTest
      * This WKT shows what SIS has in memory for debugging purpose.
      * This is normally not what we show to users.
      *
-     * @throws FactoryException if an error occurred while creating a 
transform.
-     * @throws TransformException should never happen.
+     * @throws Exception if an error occurred while loading the grid or 
transforming the coordinate.
      */
     @Test
-    public void testInternalWKT() throws FactoryException, TransformException {
+    public void testInternalWKT() throws Exception {
         createGeodeticTransformation();
         assertInternalWktEqualsRegex("(?m)\\Q" +
                 "Concat_MT[\n" +
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedTransformTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedTransformTest.java
index 98061ba8c0..72cba172ca 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedTransformTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/InterpolatedTransformTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.referencing.operation.transform;
 
+import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.Arrays;
 import org.opengis.util.FactoryException;
@@ -23,6 +24,7 @@ import org.opengis.parameter.ParameterValueGroup;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.operation.provider.NADCON;
 import org.apache.sis.referencing.operation.provider.NTv2;
+import org.apache.sis.referencing.operation.gridded.GridFileTest;
 import org.apache.sis.referencing.internal.shared.Formulas;
 
 // Test dependencies
@@ -73,12 +75,13 @@ public final class InterpolatedTransformTest extends 
MathTransformTestCase {
      *
      * @throws FactoryException if an error occurred while loading the grid.
      */
-    private void createRGF93() throws FactoryException {
+    private void createRGF93() throws URISyntaxException, FactoryException {
         final URL file = NTv2Test.class.getResource(NTv2Test.TEST_FILE);
         final NTv2 provider = new NTv2();
         final ParameterValueGroup values = 
provider.getParameters().createValue();
-        values.parameter("Latitude and longitude difference 
file").setValue(file);    // Automatic conversion from URL to Path.
-        transform = 
provider.createMathTransform(DefaultMathTransformFactory.provider(), values);
+        values.parameter("Latitude and longitude difference 
file").setValue(file);
+        GridFileTest.makeParameterRelativeToSourceFile(values);
+        transform = provider.createMathTransform(null, values);
         tolerance = Formulas.ANGULAR_TOLERANCE;
         validate();
     }
@@ -86,16 +89,17 @@ public final class InterpolatedTransformTest extends 
MathTransformTestCase {
     /**
      * Creates a transformation from NAD27 to NAD93.
      *
-     * @throws FactoryException if an error occurred while loading the grid.
+     * @throws Exception if an error occurred while loading the grid.
      */
-    private void createNADCON() throws FactoryException {
+    private void createNADCON() throws Exception {
         final URL latitudeShifts  = 
NADCONTest.class.getResource(NADCONTest.TEST_FILE + ".laa");
         final URL longitudeShifts = 
NADCONTest.class.getResource(NADCONTest.TEST_FILE + ".loa");
         final NADCON provider = new NADCON();
         final ParameterValueGroup values = 
provider.getParameters().createValue();
         values.parameter("Latitude difference file").setValue(latitudeShifts);
         values.parameter("Longitude difference 
file").setValue(longitudeShifts);
-        transform = 
provider.createMathTransform(DefaultMathTransformFactory.provider(), values);
+        GridFileTest.makeParameterRelativeToSourceFile(values);
+        transform = provider.createMathTransform(null, values);
         tolerance = NADCONTest.ANGULAR_TOLERANCE;
         validate();
     }
@@ -214,7 +218,7 @@ public final class InterpolatedTransformTest extends 
MathTransformTestCase {
      * @see InterpolatedGeocentricTransformTest#testInverseTransform()
      */
     @Test
-    public void testRGF93() throws FactoryException, TransformException {
+    public void testRGF93() throws Exception {
         createRGF93();
 
         // Forward transform
@@ -240,11 +244,10 @@ public final class InterpolatedTransformTest extends 
MathTransformTestCase {
     /**
      * Performs the tests using the transformation from NAD27 to NAD93.
      *
-     * @throws FactoryException if an error occurred while creating a 
transform.
-     * @throws TransformException if an error occurred while transforming a 
coordinate.
+     * @throws Exception if an error occurred while creating a transform or 
transforming a coordinate.
      */
     @Test
-    public void testNADCON() throws FactoryException, TransformException {
+    public void testNADCON() throws Exception {
         createNADCON();
 
         // Forward transform
@@ -262,11 +265,10 @@ public final class InterpolatedTransformTest extends 
MathTransformTestCase {
      * Tests the Well Known Text (version 1) formatting.
      * The result is what we show to users, but may be quite different than 
what SIS has in memory.
      *
-     * @throws FactoryException if an error occurred while creating a 
transform.
-     * @throws TransformException should never happen.
+     * @throws Exception if an error occurred while creating a transform.
      */
     @Test
-    public void testWKT() throws FactoryException, TransformException {
+    public void testWKT() throws Exception {
         createRGF93();
         assertWktEqualsRegex("(?m)\\Q" +
                 "PARAM_MT[“NTv2”,\n" +
diff --git 
a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
 
b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
index 191f1a4d7b..43ae7b17ff 100644
--- 
a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
+++ 
b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
@@ -26,6 +26,7 @@ import java.util.LinkedHashMap;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.io.IOException;
+import java.nio.file.Path;
 import java.time.temporal.Temporal;
 import ucar.nc2.constants.CF;       // String constants are copied by the 
compiler with no UCAR reference left.
 import ucar.nc2.constants.CDM;      // idem
@@ -189,6 +190,14 @@ final class MetadataReader extends MetadataBuilder {
         searchPath = decoder.getSearchPath();
     }
 
+    /**
+     * Returns the netCDF file as an <abbr>URI</abbr>, or {@code null} if none.
+     */
+    private URI location() {
+        final Path location = decoder.location;
+        return (location != null) ? location.toUri() : null;
+    }
+
     /**
      * Invoked when a non-fatal exception occurred while reading metadata.
      * This method sends a record to the registered listeners if any,
@@ -652,8 +661,10 @@ split:  while ((start = 
CharSequences.skipLeadingWhitespaces(value, start, lengt
          */
         final String wkt = stringValue(GEOSPATIAL_BOUNDS);
         if (wkt != null) {
-            addBoundingPolygon(new StoreFormat(null, null, decoder.geomlib, 
decoder.listeners).parseGeometry(wkt,
-                    stringValue(GEOSPATIAL_BOUNDS + "_crs"), 
stringValue(GEOSPATIAL_BOUNDS + "_vertical_crs")));
+            var parser = new StoreFormat(location(), null, null, 
decoder.geomlib, decoder.listeners);
+            addBoundingPolygon(parser.parseGeometry(wkt,
+                    stringValue(GEOSPATIAL_BOUNDS + "_crs"),
+                    stringValue(GEOSPATIAL_BOUNDS + "_vertical_crs")));
         }
         /*
          * Add a description of the format. The description is determined by 
the decoder in use.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
index 7ae3a2d2a0..772ab836b9 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
@@ -165,7 +165,7 @@ public abstract class PRJDataStore extends URIDataStore {
                 throw new DataStoreException(s.getMessage(getLocale()), 
s.exception);
             }
             final String wkt = content.toString();
-            final var format = new StoreFormat(dataLocale, timezone, null, 
listeners);
+            final var format = new StoreFormat(content.getURI(), dataLocale, 
timezone, null, listeners);
             format.setConvention(getConvention());          // Ignored if the 
format is WKT 2.
             try {
                 format.setSourceFile(content.getURI());
@@ -213,7 +213,7 @@ public abstract class PRJDataStore extends URIDataStore {
             if (crs == null) {
                 deleteAuxiliaryFile(PRJ);
             } else try (BufferedWriter out = writeAuxiliaryFile(PRJ)) {
-                final var format = new StoreFormat(dataLocale, timezone, null, 
listeners);
+                final var format = new StoreFormat(null, dataLocale, timezone, 
null, listeners);
                 format.setConvention(Convention.WKT2_2015);     // TODO: 
upgrade to newer version.
                 format.format(crs, out);
                 out.newLine();
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
index aa2996c988..544da67e8f 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
@@ -125,7 +125,7 @@ final class Store extends URIDataStore {
              * definitions.
              */
             final var pos = new ParsePosition(0);
-            final var parser = new StoreFormat(dataLocale, timezone, library, 
listeners);
+            final var parser = new StoreFormat(location, dataLocale, timezone, 
library, listeners);
             do {
                 final Object obj = parser.parse(wkt, pos);
                 objects.add(obj);
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/StoreFormat.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/StoreFormat.java
index df464ea39f..fe4b4cf2a4 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/StoreFormat.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/StoreFormat.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.storage.wkt;
 
+import java.net.URI;
 import java.text.ParseException;
 import java.time.ZoneId;
 import java.util.Locale;
@@ -62,17 +63,19 @@ public final class StoreFormat extends WKTFormat {
      * The given locale will be used for {@link InternationalString} 
localization;
      * this is <strong>not</strong> the locale for number format.
      *
+     * @param  source     the file being parsed, or {@code null} if unknown.
      * @param  locale     the locale for the new {@code Format}, or {@code 
null} for {@code Locale.ROOT}.
      * @param  timezone   the timezone, or {@code null} for UTC.
      * @param  library    the geometry library, or {@code null} for the 
default.
      * @param  listeners  where to send warnings.
      */
-    public StoreFormat(final Locale locale, final ZoneId timezone,
+    public StoreFormat(final URI source, final Locale locale, final ZoneId 
timezone,
                        final GeometryLibrary library, final StoreListeners 
listeners)
     {
         super(locale, timezone);
         this.library   = library;
         this.listeners = listeners;
+        setSourceFile(source);
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/Authorization.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/Authorization.java
new file mode 100644
index 0000000000..0349db0905
--- /dev/null
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/Authorization.java
@@ -0,0 +1,59 @@
+/*
+ * 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.sis.io;
+
+import java.nio.file.AccessDeniedException;
+
+
+/**
+ * Indication of whether access to a file or <abbr>URL</abbr> is granted or 
denied.
+ * A file may be specified in a {@code xlink:href} attribute of an 
<abbr>XML</abbr> document,
+ * or as a parameter in the definition of a coordinate operation (e.g. a datum 
shift grid file).
+ * By default, Apache <abbr>SIS</abbr> opens these files only if they are in 
dedicated directories.
+ * This enumeration is used when the default behavior is replaced by user 
policy.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.7
+ * @since   1.7
+ */
+public enum Authorization {
+    /**
+     * Access to the file or <abbr>URL</abbr> is authorized.
+     * The file may be opened and its content read.
+     */
+    GRANTED,
+
+    /**
+     * Access to the file or <abbr>URL</abbr> is denied.
+     * It may result in an {@link AccessDeniedException} to be thrown.
+     */
+    DENIED,
+
+    /**
+     * Access to the file or <abbr>URL</abbr> is determined by Apache 
<abbr>SIS</abbr> default policy.
+     * These defaults depend on the type of document containing references by 
<abbr>URL</abbr>s.
+     * Examples:
+     *
+     * <ul>
+     *   <li>In a <abbr>GML</abbr> document, follow {@code xlink:href} only if 
the reference is local to the document.</li>
+     *   <li>In coordinate operations defined in <abbr>JSON</abbr>, 
<abbr>GML</abbr> or <abbr>WKT</abbr> documents,
+     *       read datum shift grid file only if inside the {@code 
$SIS_DATA/DatumChanges} directory or in the same
+     *       directory or server as the document.</li>
+     * </ul>
+     */
+    DEFAULT
+}
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
index 7e54a9c545..b9ccfdb8e1 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
@@ -16,29 +16,21 @@
  */
 
 /**
- * Extensions to standard Java I/O ({@link java.io.Reader}, {@link 
java.io.Writer},
- * {@link java.lang.Appendable}) and {@link java.text.Format}.
- * Many classes defined in this package are filters applying on-the-fly 
formatting while writing
- * text to the output device. For example, {@link 
org.apache.sis.io.LineAppender} can wrap lines
- * to some maximal line length (e.g. 80 characters), and {@link 
org.apache.sis.io.TableAppender}
- * replaces all occurrence of {@code '\t'} by the number of spaces needed for 
producing a tabular
- * output.
+ * Extensions to standard Java I/O and formatting <abbr>API</abbr>.
+ * This package provides subtypes or utility methods for the
+ * {@link java.io.Reader}, {@link java.io.Writer}, {@link 
java.lang.Appendable} and {@link java.text.Format} classes.
+ * Some subclasses are filters applying on-the-fly formatting while writing 
text to the output stream.
+ * For example, {@link org.apache.sis.io.LineAppender} can wrap lines to some 
maximal line length (e.g. 80 characters),
+ * and {@link org.apache.sis.io.TableAppender} replaces all occurrence of 
{@code '\t'} by the number of spaces needed
+ * for producing a tabular output.
  *
- * <div class="note"><b>Note:</b>
- * One of the formatter classes defined in this package is a {@link 
java.text.Format java.text.Format} subclass.
- * While traditionally though as part of {@code text} packages, that {@code 
Format} is defined in this I/O package
- * because it can format to an {@link java.lang.Appendable} and for 
consistency with the {@link org.apache.sis.io.wkt}
- * package.</div>
+ * <h2>Unicode characters usage in <abbr>SIS</abbr></h2>
+ * Some classes in this package make extensive use of Unicode characters, in 
particular for the formatting of trees and tables.
+ * Outputs printed to {@link java.lang.System#out} may not appear correctly if 
the character encoding of the console is not the
+ * character encoding specified by the {@code stdout.encoding} system property 
(usually <abbr>UTF</abbr>-8).
  *
- * <h2>Unicode characters</h2>
- * Some formatters in this package make extensive use of Unicode characters. 
This may produce
- * unexpected results in a Windows console, unless the underlying output 
stream uses the correct
- * encoding (e.g. {@code new OutputStreamWriter(System.out, "Cp437")}). To 
display the appropriate
- * code page for a Windows console, type {@code chcp} on the command line.
- *
- * <h2>Supplementary Unicode characters</h2>
- * This package can handle the {@linkplain 
java.lang.Character#isSupplementaryCodePoint(int)
- * Unicode supplementary characters}.
+ * <p>This package, like most of Apache <abbr>SIS</abbr> library, can handle 
the
+ * {@linkplain java.lang.Character#isSupplementaryCodePoint(int) Unicode 
supplementary characters}.</p>
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @version 1.7
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/system/DataURI.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/system/DataURI.java
new file mode 100644
index 0000000000..e78471652c
--- /dev/null
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/system/DataURI.java
@@ -0,0 +1,211 @@
+/*
+ * 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.sis.system;
+
+import java.util.Optional;
+import java.net.URI;
+import java.io.InputStreamReader;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Files;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import org.apache.sis.io.Authorization;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.internal.shared.Strings;
+
+
+/**
+ * Helper base class for services which will access an user-specified 
<abbr>URI</abbr>.
+ * This is a support class for access control. For example, the 
<abbr>URI</abbr> can be
+ * restricted to the directory specified by {@link DataDirectory}.
+ *
+ * <p>Instances of this class should be temporary.
+ * This is an helper class for loading data and discarded after the loading 
completed.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @see DataDirectory
+ * @see Authorization
+ */
+public class DataURI {
+    /**
+     * The <abbr>URI</abbr> specified in parameter, usually relative to an 
unspecified directory.
+     * This information is stored for formatting error messages in case of 
failure to load data.
+     * For reading the data, use {@link #resolved()} instead.
+    *
+     * @see #resolved()
+     */
+    public final URI parameter;
+
+    /**
+     * The <abbr>URI</abbr> parameter as a resolved (usually absolute) and 
normalized reference.
+     */
+    private URI resolved;
+
+    /**
+     * The resolved <abbr>URI</abbr> as a path, or {@code null} if not 
convertible.
+     */
+    private Path asPath;
+
+    /**
+     * Errors that occurred while trying to resolve the <abbr>URI</abbr> or 
convert it to a path.
+     * Used for logging purpose or for declaration as the cause of another 
exception.
+     */
+    protected Exception error;
+
+    /**
+     * Whether the resolved <abbr>URI</abbr> is relative to the last specified 
base.
+     * This is {@code true} if the last call to {@link #tryResolve(URI)} 
resulted in an <abbr>URI</abbr>
+     * starting with the given base. This information can be used for access 
control, in order to ensure
+     * that the file is inside the expected directory.
+     */
+    protected boolean isRelative;
+
+    /**
+     * Creates a new instance for the given user-specified <abbr>URI</abbr>.
+     *
+     * @param  parameter  the <abbr>URI</abbr> from a user-specified parameter.
+     */
+    protected DataURI(final URI parameter) {
+        this.parameter = parameter;
+    }
+
+    /**
+     * Tries to resolve the user-specified parameter relatively to the given 
base.
+     * This method returns {@code true} if the parameter has been resolved, 
not necessarily by using
+     * the given {@code base} parameter. For distinguishing whether the 
resolved <abbr>URI</abbr> is
+     * relative to the given base, see the {@link #isRelative} flag.
+     *
+     * <p>If the {@code base} argument is a file instead of a directory, then 
this method
+     * resolves the user-specified {@linkplain #parameter} as a sibling of the 
given file.
+     * This is {@link URI#resolve(URI)} standard behavior, not a special case 
of this method.
+     * This behavior is useful when a file is expected to be found in the same 
directory as the
+     * <abbr>JSON</abbr>, <abbr>GML</abbr> or <abbr>WKT</abbr> document 
containing the parameter.</p>
+     *
+     * @param  base  base directory, or {@code null} if unknown.
+     * @return whether the parameter could be resolved.
+     */
+    protected final boolean tryResolve(final URI base) {
+        if (base == null || parameter == null) {
+            return false;
+        }
+        final URI result = base.resolve(parameter).normalize();
+        if (result != resolved) {
+            if (result == parameter) {
+                isRelative = false;
+            } else {
+                String path = Strings.orEmpty(base.getPath());
+                path = path.substring(0, path.lastIndexOf('/') + 1);
+                isRelative = 
Strings.orEmpty(result.getPath()).startsWith(path);
+            }
+            resolved = result;
+            try {
+                asPath = Path.of(result);
+            } catch (IllegalArgumentException | FileSystemNotFoundException e) 
{
+                if (error == null) error = e;
+                else error.addSuppressed(e);
+                asPath = null;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns {@code true} if the file is inside the expected directory but 
does not exists.
+     * In case of doubt, or if the file is outside the expected directory, 
returns {@code false}.
+     *
+     * @return {@code true} if the path does <em>not</em> exists.
+     */
+    protected final boolean isFileMissing() {
+        return isRelative && (asPath != null) && Files.notExists(asPath);
+    }
+
+    /**
+     * Returns an error message saying that we are not authorized to read from 
the user-provided <abbr>URL</abbr>.
+     *
+     * @return error message to provide in the exception to be thrown.
+     */
+    protected final String accessDenied() {
+        return Errors.format(Errors.Keys.AccessDenied_1, (asPath != null) ? 
asPath : resolved);
+    }
+
+    /**
+     * Returns the <abbr>URI</abbr> parameter as a resolved (usually absolute) 
and normalized reference.
+     *
+     * @return the resolved and normalized <abbr>URI</abbr>, or {@code null} 
if none.
+     *
+     * @see #parameter
+     */
+    public final URI resolved() {
+        return resolved;
+    }
+
+    /**
+     * Returns the resolved <abbr>URI</abbr> as a path if possible.
+     * A use case for this method is grids to open as a {@link 
org.apache.sis.storage.DataStore}.
+     *
+     * @return the resolved <abbr>URI</abbr> as a path.
+     */
+    public final Optional<Path> path() {
+        return Optional.ofNullable(asPath);
+    }
+
+    /**
+     * Creates a channel for reading bytes from the file at the path specified 
at construction time.
+     * This method tries to open using the file system before to open from the 
<abbr>URL</abbr>.
+     * Caller should have verified authorization before to invoke this method.
+     *
+     * @return a channel for reading bytes from the file.
+     * @throws IOException if the channel cannot be created.
+     */
+    public final ReadableByteChannel newByteChannel() throws IOException {
+        if (asPath != null) {
+            return Files.newByteChannel(asPath);
+        } else {
+            return Channels.newChannel(resolved.toURL().openStream());
+        }
+    }
+
+    /**
+     * Creates a buffered reader for reading characters from the file at the 
path specified at construction time.
+     * This method tries to open using the file system before to open from the 
<abbr>URL</abbr>.
+     * Caller should have verified authorization before to invoke this method.
+     *
+     * @return a channel for reading bytes from the file.
+     * @throws IOException if the reader cannot be created.
+     */
+    public final BufferedReader newBufferedReader() throws IOException {
+        if (asPath != null) {
+            return Files.newBufferedReader(asPath);
+        } else {
+            return new BufferedReader(new 
InputStreamReader(resolved.toURL().openStream()));
+        }
+    }
+
+    /**
+     * Returns a string representation of this path for debugging purposes.
+     *
+     * @return string representation for debugging purposes.
+     */
+    @Override
+    public String toString() {
+        return String.valueOf(resolved != null ? resolved : parameter);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
index ca443d01ff..446813058d 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
@@ -71,6 +71,11 @@ public class Errors extends IndexedResourceBundle {
             throw new IllegalAccessException();
         }
 
+        /**
+         * Access to the “{0}” file has been denied by application 
configuration.
+         */
+        public static final short AccessDenied_1 = 208;
+
         /**
          * ‘{0}’ is already initialized.
          */
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
index 14a647c6b6..de7b59e6e8 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
@@ -24,6 +24,7 @@
 # programmatic parameters do not have to be last in the formatted text, since 
each localized message
 # can reorder the parameters as they want.
 #
+AccessDenied_1                    = Access to the \u201c{0}\u201d file has 
been denied by application configuration.
 AlreadyInitialized_1              = \u2018{0}\u2019 is already initialized.
 AmbiguousName_3                   = Name \u201c{2}\u201d is ambiguous because 
it can be understood as either \u201c{0}\u201d or \u201c{1}\u201d.
 AmbiguousType_2                   = Type \u2018{1}\u2019 is ambiguous because 
it implements more than one subtype of \u2018{0}\u2019.
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
index e0a2b070fc..cde32ec641 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
@@ -21,9 +21,10 @@
 #   U+202F NARROW NO-BREAK SPACE  before  ; ! and ?
 #   U+00A0 NO-BREAK SPACE         before  :
 #
+AccessDenied_1                    = L\u2019acc\u00e8s au fichier 
\u00ab\u202f{0}\u202f\u00bb a \u00e9t\u00e9 refus\u00e9 par la configuration de 
l\u2019application.
 AlreadyInitialized_1              = \u2018{0}\u2019 est d\u00e9j\u00e0 
initialis\u00e9.
 AmbiguousName_3                   = Le nom \u00ab\u202f{2}\u202f\u00bb est 
ambigu\u00eb car il peut \u00eatre interpr\u00e9t\u00e9 comme 
\u00ab\u202f{0}\u202f\u00bb ou \u00ab\u202f{1}\u202f\u00bb.
-AmbiguousType_2                   = Le type \u2018{1}\u2019 est ambigu\u00eb 
car il impl\u00e9mente plus qu'un sous-type de \u2018{0}\u2019.
+AmbiguousType_2                   = Le type \u2018{1}\u2019 est ambigu\u00eb 
car il impl\u00e9mente plus qu\u2019un sous-type de \u2018{0}\u2019.
 BackgroundComputationFailed       = Le calcul en arri\u00e8re-plan a 
\u00e9chou\u00e9.
 CanIterateOnlyOnce                = Cet objet ne peut it\u00e9rer qu\u2019une 
seule fois.
 CanNotAddToExclusiveSet_2         = Aucun \u00e9l\u00e9ment ne peut \u00eatre 
ajout\u00e9 \u00e0 cet ensemble car les propri\u00e9t\u00e9s \u2018{0}\u2019 et 
\u2018{1}\u2019 sont mutuellement exclusives.

Reply via email to