diff --git a/exist-ant/src/test/resources-filtered/conf.xml b/exist-ant/src/test/resources-filtered/conf.xml index 52cac5dde3f..c440bf37ce8 100644 --- a/exist-ant/src/test/resources-filtered/conf.xml +++ b/exist-ant/src/test/resources-filtered/conf.xml @@ -753,6 +753,7 @@ + diff --git a/exist-core/src/test/resources-filtered/conf.xml b/exist-core/src/test/resources-filtered/conf.xml index 9a76f8c79a5..9cea41a2697 100644 --- a/exist-core/src/test/resources-filtered/conf.xml +++ b/exist-core/src/test/resources-filtered/conf.xml @@ -910,6 +910,7 @@ + diff --git a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml index 15d68dea5fb..374d74b1609 100644 --- a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml @@ -901,6 +901,7 @@ + diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml index b9bc14f5b53..b45582d4cc9 100644 --- a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml @@ -920,6 +920,7 @@ + diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml index 7f2354f9f40..eca4d7dd17e 100644 --- a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml @@ -912,6 +912,7 @@ + diff --git a/exist-distribution/pom.xml b/exist-distribution/pom.xml index 2369cda5832..6804e2d8a4b 100644 --- a/exist-distribution/pom.xml +++ b/exist-distribution/pom.xml @@ -213,6 +213,12 @@ ${project.version} runtime + + ${project.groupId} + exist-expath-file + ${project.version} + runtime + ${project.groupId} exist-file diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml index 6a9937c0e03..c50171aed5f 100644 --- a/exist-distribution/src/main/config/conf.xml +++ b/exist-distribution/src/main/config/conf.xml @@ -1053,6 +1053,7 @@ + diff --git a/extensions/contentextraction/src/test/resources-filtered/conf.xml b/extensions/contentextraction/src/test/resources-filtered/conf.xml index 1311e06f555..186547cca32 100644 --- a/extensions/contentextraction/src/test/resources-filtered/conf.xml +++ b/extensions/contentextraction/src/test/resources-filtered/conf.xml @@ -757,6 +757,7 @@ + diff --git a/extensions/debuggee/src/test/resources-filtered/conf.xml b/extensions/debuggee/src/test/resources-filtered/conf.xml index 5dc0efc380a..7fd8a40007e 100644 --- a/extensions/debuggee/src/test/resources-filtered/conf.xml +++ b/extensions/debuggee/src/test/resources-filtered/conf.xml @@ -743,6 +743,7 @@ + diff --git a/extensions/expath/src/test/resources-filtered/conf.xml b/extensions/expath/src/test/resources-filtered/conf.xml index a0e02a2c06d..f2c925d3bf7 100644 --- a/extensions/expath/src/test/resources-filtered/conf.xml +++ b/extensions/expath/src/test/resources-filtered/conf.xml @@ -757,6 +757,7 @@ + diff --git a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml index 697afdbf11b..536cccfd156 100644 --- a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml +++ b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml @@ -738,6 +738,7 @@ + diff --git a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml index 2aae0f7d207..f6701fb9518 100644 --- a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml @@ -906,6 +906,7 @@ + diff --git a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml index 4eaa2642bde..b1c8bb1e581 100644 --- a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml @@ -905,6 +905,7 @@ + diff --git a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml index 7b290c22429..b0fcab0aaaf 100644 --- a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml @@ -903,6 +903,7 @@ + diff --git a/extensions/indexes/range/src/test/resources-filtered/conf.xml b/extensions/indexes/range/src/test/resources-filtered/conf.xml index a22d440f625..d1a0cae7694 100644 --- a/extensions/indexes/range/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/range/src/test/resources-filtered/conf.xml @@ -908,6 +908,7 @@ + diff --git a/extensions/indexes/sort/src/test/resources-filtered/conf.xml b/extensions/indexes/sort/src/test/resources-filtered/conf.xml index e6d70cea684..46a9ab8c07c 100644 --- a/extensions/indexes/sort/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/sort/src/test/resources-filtered/conf.xml @@ -903,6 +903,7 @@ + diff --git a/extensions/indexes/spatial/src/test/resources-filtered/conf.xml b/extensions/indexes/spatial/src/test/resources-filtered/conf.xml index b3ea3200f72..31de61038a9 100644 --- a/extensions/indexes/spatial/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/spatial/src/test/resources-filtered/conf.xml @@ -889,6 +889,7 @@ + diff --git a/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml b/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml index 2c516c836c9..f76017f9171 100644 --- a/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml @@ -74,6 +74,7 @@ + diff --git a/extensions/modules/cache/src/test/resources-filtered/conf.xml b/extensions/modules/cache/src/test/resources-filtered/conf.xml index af9663be608..aaf22a85647 100644 --- a/extensions/modules/cache/src/test/resources-filtered/conf.xml +++ b/extensions/modules/cache/src/test/resources-filtered/conf.xml @@ -760,6 +760,7 @@ + diff --git a/extensions/modules/compression/src/test/resources-filtered/conf.xml b/extensions/modules/compression/src/test/resources-filtered/conf.xml index 0bdebfee2d6..464b4bb0958 100644 --- a/extensions/modules/compression/src/test/resources-filtered/conf.xml +++ b/extensions/modules/compression/src/test/resources-filtered/conf.xml @@ -757,6 +757,7 @@ + diff --git a/extensions/modules/counter/src/test/resources-filtered/conf.xml b/extensions/modules/counter/src/test/resources-filtered/conf.xml index 1a31ae00a0e..1958481eeda 100644 --- a/extensions/modules/counter/src/test/resources-filtered/conf.xml +++ b/extensions/modules/counter/src/test/resources-filtered/conf.xml @@ -746,6 +746,7 @@ + diff --git a/extensions/modules/expath-file/pom.xml b/extensions/modules/expath-file/pom.xml new file mode 100644 index 00000000000..1eba38e8ffe --- /dev/null +++ b/extensions/modules/expath-file/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + + org.exist-db + exist-parent + 7.0.0-SNAPSHOT + ../../../exist-parent + + + exist-expath-file + jar + + eXist-db EXPath File Module + EXPath File Module 4.0 for eXist-db (http://expath.org/ns/file) + + + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + HEAD + + + + + org.exist-db + exist-core + ${project.version} + + + + + diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java new file mode 100644 index 00000000000..ee7dda37d76 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java @@ -0,0 +1,73 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import org.exist.dom.QName; +import org.exist.xquery.ErrorCodes.ErrorCode; + +/** + * Error codes defined by the EXPath File Module 4.0. + * + * @see EXPath File Module 4.0 + */ +public class ExpathFileErrorCode { + + public static final ErrorCode NOT_FOUND = new ErrorCode( + new QName("not-found", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path does not exist."); + + public static final ErrorCode INVALID_PATH = new ErrorCode( + new QName("invalid-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path is invalid."); + + public static final ErrorCode EXISTS = new ErrorCode( + new QName("exists", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path already exists."); + + public static final ErrorCode NO_DIR = new ErrorCode( + new QName("no-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path does not point to a directory."); + + public static final ErrorCode IS_DIR = new ErrorCode( + new QName("is-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path points to a directory."); + + public static final ErrorCode IS_RELATIVE = new ErrorCode( + new QName("is-relative", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path is relative."); + + public static final ErrorCode UNKNOWN_ENCODING = new ErrorCode( + new QName("unknown-encoding", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified encoding is not supported."); + + public static final ErrorCode OUT_OF_RANGE = new ErrorCode( + new QName("out-of-range", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified offset or length is out of range."); + + public static final ErrorCode IO_ERROR = new ErrorCode( + new QName("io-error", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "A generic file system error occurred."); + + private ExpathFileErrorCode() { + // no instances + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java new file mode 100644 index 00000000000..07fec3e3332 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java @@ -0,0 +1,148 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.util.List; +import java.util.Map; + +import org.exist.xquery.AbstractInternalModule; +import org.exist.xquery.FunctionDef; + +/** + * EXPath File Module 4.0 implementation for eXist-db. + * + * @see EXPath File Module 4.0 + */ +public class ExpathFileModule extends AbstractInternalModule { + + public static final String NAMESPACE_URI = "http://expath.org/ns/file"; + public static final String PREFIX = "exfile"; + public static final String INCLUSION_DATE = "2025-05-01"; + public static final String RELEASED_IN_VERSION = "7.0.0"; + + private static final FunctionDef[] functions = { + // FileProperties: exists, is-dir, is-file, is-absolute, last-modified, size(1), size(2) + new FunctionDef(FileProperties.signatures[0], FileProperties.class), + new FunctionDef(FileProperties.signatures[1], FileProperties.class), + new FunctionDef(FileProperties.signatures[2], FileProperties.class), + new FunctionDef(FileProperties.signatures[3], FileProperties.class), + new FunctionDef(FileProperties.signatures[4], FileProperties.class), + new FunctionDef(FileProperties.signatures[5], FileProperties.class), + new FunctionDef(FileProperties.signatures[6], FileProperties.class), + + // FileIO: read-text(1), read-text(2), read-text-lines(1), read-text-lines(2), + // read-text(3-fallback), read-text-lines(3-fallback), + // read-binary(1), read-binary(2), read-binary(3) + new FunctionDef(FileIO.signatures[0], FileIO.class), + new FunctionDef(FileIO.signatures[1], FileIO.class), + new FunctionDef(FileIO.signatures[2], FileIO.class), + new FunctionDef(FileIO.signatures[3], FileIO.class), + new FunctionDef(FileIO.signatures[4], FileIO.class), + new FunctionDef(FileIO.signatures[5], FileIO.class), + new FunctionDef(FileIO.signatures[6], FileIO.class), + new FunctionDef(FileIO.signatures[7], FileIO.class), + new FunctionDef(FileIO.signatures[8], FileIO.class), + + // FileWrite: write(2), write(3), write-text(2), write-text(3), + // write-text-lines(2), write-text-lines(3), write-binary(2), write-binary(3) + new FunctionDef(FileWrite.signatures[0], FileWrite.class), + new FunctionDef(FileWrite.signatures[1], FileWrite.class), + new FunctionDef(FileWrite.signatures[2], FileWrite.class), + new FunctionDef(FileWrite.signatures[3], FileWrite.class), + new FunctionDef(FileWrite.signatures[4], FileWrite.class), + new FunctionDef(FileWrite.signatures[5], FileWrite.class), + new FunctionDef(FileWrite.signatures[6], FileWrite.class), + new FunctionDef(FileWrite.signatures[7], FileWrite.class), + + // FileAppend: append(2), append(3), append-binary, append-text(2), append-text(3), + // append-text-lines(2), append-text-lines(3) + new FunctionDef(FileAppend.signatures[0], FileAppend.class), + new FunctionDef(FileAppend.signatures[1], FileAppend.class), + new FunctionDef(FileAppend.signatures[2], FileAppend.class), + new FunctionDef(FileAppend.signatures[3], FileAppend.class), + new FunctionDef(FileAppend.signatures[4], FileAppend.class), + new FunctionDef(FileAppend.signatures[5], FileAppend.class), + new FunctionDef(FileAppend.signatures[6], FileAppend.class), + + // FileManipulation: copy, move, delete(1), delete(2), create-dir, + // create-temp-dir(2), create-temp-dir(3), + // create-temp-file(2), create-temp-file(3), + // list(1), list(2), list(3), + // children, descendants, list-roots + new FunctionDef(FileManipulation.signatures[0], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[1], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[2], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[3], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[4], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[5], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[6], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[7], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[8], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[9], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[10], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[11], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[12], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[13], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[14], FileManipulation.class), + + // FilePaths: name, parent, path-to-native, path-to-uri, resolve-path(1), resolve-path(2) + new FunctionDef(FilePaths.signatures[0], FilePaths.class), + new FunctionDef(FilePaths.signatures[1], FilePaths.class), + new FunctionDef(FilePaths.signatures[2], FilePaths.class), + new FunctionDef(FilePaths.signatures[3], FilePaths.class), + new FunctionDef(FilePaths.signatures[4], FilePaths.class), + new FunctionDef(FilePaths.signatures[5], FilePaths.class), + + // FileSystemProperties: dir-separator, line-separator, path-separator, + // temp-dir, base-dir, current-dir + new FunctionDef(FileSystemProperties.signatures[0], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[1], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[2], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[3], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[4], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[5], FileSystemProperties.class) + }; + + public ExpathFileModule(final Map> parameters) { + super(functions, parameters); + } + + @Override + public String getNamespaceURI() { + return NAMESPACE_URI; + } + + @Override + public String getDefaultPrefix() { + return PREFIX; + } + + @Override + public String getDescription() { + return "EXPath File Module 4.0 - http://expath.org/ns/file"; + } + + @Override + public String getReleaseVersion() { + return RELEASED_IN_VERSION; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java new file mode 100644 index 00000000000..8339a1dd1a7 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java @@ -0,0 +1,137 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.exist.xquery.Expression; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; + +/** + * Helper utilities for the EXPath File Module. + */ +public class ExpathFileModuleHelper { + + private ExpathFileModuleHelper() { + // no instances + } + + /** + * Check that the calling user has DBA role. + * + * @param context the XQuery context + * @param expression the calling expression (for error reporting) + * @throws XPathException if the user is not a DBA + */ + public static void checkDbaRole(final XQueryContext context, final Expression expression) throws XPathException { + if (!context.getSubject().hasDbaRole()) { + throw new XPathException(expression, + "Permission denied, calling user '" + context.getSubject().getName() + + "' must be a DBA to call this function."); + } + } + + /** + * Resolve a path string (file: URI or native path) to a {@link Path}. + * Relative paths are resolved against the JVM working directory. + * + * @param path the path string or file: URI + * @param expression the calling expression (for error reporting) + * @return the resolved Path + * @throws XPathException if the path is invalid + */ + public static Path getPath(final String path, final Expression expression) throws XPathException { + return getPath(path, expression, null); + } + + /** + * Resolve a path string (file: URI or native path) to a {@link Path}. + * Relative paths are resolved against the XQuery static base URI if it is a + * file: URI, otherwise against the JVM working directory. + * + * @param path the path string or file: URI + * @param expression the calling expression (for error reporting) + * @param context the XQuery context (may be null) + * @return the resolved Path + * @throws XPathException if the path is invalid + */ + public static Path getPath(final String path, final Expression expression, final XQueryContext context) throws XPathException { + try { + if (path.startsWith("file:")) { + return Paths.get(new URI(path)); + } + + final Path p = Paths.get(path); + if (p.isAbsolute()) { + return p; + } + + // Resolve relative paths against static base URI if available + if (context != null) { + try { + final String baseUri = context.getBaseURI().getStringValue(); + if (baseUri != null && baseUri.startsWith("file:")) { + final Path basePath = Paths.get(new URI(baseUri)); + // Base URI may point to a file; resolve against its parent directory + final Path baseDir = java.nio.file.Files.isDirectory(basePath) ? basePath : basePath.getParent(); + if (baseDir != null) { + return baseDir.resolve(p); + } + } + } catch (final Exception ignored) { + // Fall through to default resolution + } + } + + return p; + } catch (final InvalidPathException e) { + throw new XPathException(expression, ExpathFileErrorCode.INVALID_PATH, + "Invalid path: " + path + " - " + e.getMessage()); + } catch (final Exception e) { + throw new XPathException(expression, ExpathFileErrorCode.INVALID_PATH, + path + " is not a valid path or URI: " + e.getMessage()); + } + } + + /** + * Validate and return a {@link Charset} for the given encoding name. + * + * @param encoding the encoding name + * @param expression the calling expression (for error reporting) + * @return the Charset + * @throws XPathException if the encoding is not supported + */ + public static Charset getCharset(final String encoding, final Expression expression) throws XPathException { + try { + return Charset.forName(encoding); + } catch (final UnsupportedCharsetException e) { + throw new XPathException(expression, ExpathFileErrorCode.UNKNOWN_ENCODING, + "Unknown encoding: " + encoding); + } + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java new file mode 100644 index 00000000000..03f9d706665 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java @@ -0,0 +1,256 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Properties; + +import org.exist.dom.QName; +import org.exist.storage.serializers.Serializer; +import org.exist.util.serializer.SAXSerializer; +import org.exist.util.serializer.SerializerPool; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.AbstractMapType; +import org.exist.xquery.util.SerializerUtils; +import org.exist.xquery.value.*; +import org.xml.sax.SAXException; + +/** + * EXPath File Module 4.0 - Append functions. + *

+ * Implements: file:append, file:append-binary, file:append-text, file:append-text-lines + */ +public class FileAppend extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, "The path to the file."); + + public static final FunctionSignature[] signatures = { + // file:append($file, $value) + new FunctionSignature( + new QName("append", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a serialized sequence to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append($file, $value, $options) + new FunctionSignature( + new QName("append", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a serialized sequence to a file with serialization options.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and append."), + new FunctionParameterSequenceType("options", Type.ITEM, Cardinality.ZERO_OR_ONE, "Serialization parameters as map(*) or element(output:serialization-parameters).") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-binary($file, $value) + new FunctionSignature( + new QName("append-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends binary data to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text($file, $value) + new FunctionSignature( + new QName("append-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a string to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text($file, $value, $encoding) + new FunctionSignature( + new QName("append-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a string to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to append."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text-lines($file, $lines) + new FunctionSignature( + new QName("append-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a sequence of strings as lines to a file, separated by the platform line separator.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("lines", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text-lines($file, $lines, $encoding) + new FunctionSignature( + new QName("append-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a sequence of strings as lines to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("lines", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to append."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ) + }; + + public FileAppend(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + checkParentDir(path); + + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("append")) { + return append(path, args); + } else if (isCalledAs("append-binary")) { + return appendBinary(path, args); + } else if (isCalledAs("append-text")) { + return appendText(path, args); + } else if (isCalledAs("append-text-lines")) { + return appendTextLines(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence append(final Path path, final Sequence[] args) throws XPathException { + final Sequence value = args[1]; + final Properties outputProperties = new Properties(); + if (args.length > 2 && !args[2].isEmpty()) { + final Item optionsItem = args[2].itemAt(0); + if (optionsItem instanceof AbstractMapType) { + outputProperties.putAll(SerializerUtils.getSerializationOptions(this, (AbstractMapType) optionsItem)); + } else if (optionsItem instanceof NodeValue) { + SerializerUtils.getSerializationOptions(this, (NodeValue) optionsItem, outputProperties); + } + } + + final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class); + try { + final Serializer serializer = context.getBroker().borrowSerializer(); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND)), + StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + serializer.setProperties(outputProperties); + serializer.setSAXHandlers(sax, sax); + + for (final SequenceIterator i = value.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (Type.subTypeOf(item.getType(), Type.NODE)) { + serializer.toSAX((NodeValue) item); + } else { + writer.write(item.getStringValue()); + } + } + } finally { + context.getBroker().returnSerializer(serializer); + } + } catch (final IOException | SAXException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } finally { + SerializerPool.getInstance().returnObject(sax); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendBinary(final Path path, final Sequence[] args) throws XPathException { + final BinaryValue binaryValue = (BinaryValue) args[1].itemAt(0); + try (final OutputStream os = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + final InputStream is = binaryValue.getInputStream()) { + is.transferTo(os); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendText(final Path path, final Sequence[] args) throws XPathException { + final String text = args[1].getStringValue(); + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND), encoding)) { + writer.write(text); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND), encoding)) { + final String lineSep = System.lineSeparator(); + for (final SequenceIterator i = args[1].iterate(); i.hasNext(); ) { + writer.write(i.nextItem().getStringValue()); + writer.write(lineSep); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void checkParentDir(final Path path) throws XPathException { + final Path parent = path.getParent(); + if (parent != null && !Files.isDirectory(parent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent directory does not exist: " + parent.toAbsolutePath()); + } + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java new file mode 100644 index 00000000000..412e16f4c0e --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java @@ -0,0 +1,323 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Base64BinaryValueType; +import org.exist.xquery.value.BinaryValueFromBinaryString; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; + +/** + * EXPath File Module 4.0 - Input functions. + *

+ * Implements: file:read-text, file:read-text-lines, file:read-binary + */ +public class FileIO extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, + "The path to the file."); + private static final FunctionParameterSequenceType ENCODING_PARAM = + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, + "The character encoding. Default: UTF-8."); + + public static final FunctionSignature[] signatures = { + // file:read-text($file as xs:string) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text($file as xs:string, $encoding as xs:string) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text with the specified encoding.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text-lines($file as xs:string) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-text-lines($file as xs:string, $encoding as xs:string) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines with the specified encoding.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-text($file as xs:string, $encoding as xs:string?, $fallback as xs:boolean) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text. If $fallback is true, invalid characters are replaced.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM, + new FunctionParameterSequenceType("fallback", Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "If true, replace invalid characters with the Unicode replacement character.")}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text-lines($file as xs:string, $encoding as xs:string?, $fallback as xs:boolean) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines. If $fallback is true, invalid characters are replaced.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM, + new FunctionParameterSequenceType("fallback", Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "If true, replace invalid characters with the Unicode replacement character.")}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-binary($file as xs:string) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as binary.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ), + // file:read-binary($file as xs:string, $offset as xs:integer) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads a portion of a file as binary starting at the given byte offset.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The byte offset to start reading from. Default: 0.") + }, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ), + // file:read-binary($file as xs:string, $offset as xs:integer, $length as xs:integer) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads a portion of a file as binary with offset and length.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The byte offset to start reading from. Default: 0."), + new FunctionParameterSequenceType("length", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The number of bytes to read.") + }, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ) + }; + + public FileIO(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "File not found: " + path.toAbsolutePath()); + } + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("read-text")) { + return readText(path, args); + } else if (isCalledAs("read-text-lines")) { + return readTextLines(path, args); + } else if (isCalledAs("read-binary")) { + return readBinary(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence readText(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 1); + final boolean fallback = args.length > 2 && !args[2].isEmpty() + && args[2].itemAt(0).toJavaObject(Boolean.class); + try { + final String content = readFileText(path, encoding, fallback); + // Normalize newlines per spec: CR or CRLF -> LF + final String normalized = content.replace("\r\n", "\n").replace("\r", "\n"); + return new StringValue(this, normalized); + } catch (final java.nio.charset.MalformedInputException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, + "Invalid characters in file for encoding " + encoding.name()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence readTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 1); + final boolean fallback = args.length > 2 && !args[2].isEmpty() + && args[2].itemAt(0).toJavaObject(Boolean.class); + try { + final String content = readFileText(path, encoding, fallback); + // Split at newline boundaries per spec + final String[] lines = content.split("\r\n|\r|\n", -1); + final ValueSequence result = new ValueSequence(lines.length); + // If file ends with newline, last split element is empty - exclude it per spec + final int count = (lines.length > 0 && lines[lines.length - 1].isEmpty()) ? lines.length - 1 : lines.length; + for (int i = 0; i < count; i++) { + result.add(new StringValue(this, lines[i])); + } + return result; + } catch (final java.nio.charset.MalformedInputException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, + "Invalid characters in file for encoding " + encoding.name()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence readBinary(final Path path, final Sequence[] args) throws XPathException { + final long offset = args.length > 1 && !args[1].isEmpty() ? args[1].itemAt(0).toJavaObject(Long.class) : 0; + final boolean hasLength = args.length > 2 && !args[2].isEmpty(); + final long length = hasLength ? args[2].itemAt(0).toJavaObject(Long.class) : -1; + + try { + final long fileSize = Files.size(path); + validateBinaryRange(offset, length, hasLength, fileSize); + + final byte[] data = readBinaryData(path, offset, hasLength, length, fileSize); + final String base64 = java.util.Base64.getEncoder().encodeToString(data); + return new BinaryValueFromBinaryString(this, new Base64BinaryValueType(), base64); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private void validateBinaryRange(final long offset, final long length, final boolean hasLength, final long fileSize) throws XPathException { + if (offset < 0 || offset > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset " + offset + " is out of range for file of size " + fileSize); + } + if (hasLength && length < 0) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Length must not be negative: " + length); + } + if (hasLength && offset + length > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset + length exceeds file size: " + (offset + length) + " > " + fileSize); + } + } + + private byte[] readBinaryData(final Path path, final long offset, final boolean hasLength, final long length, final long fileSize) throws IOException { + if (offset == 0 && !hasLength) { + return Files.readAllBytes(path); + } + try (final RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) { + raf.seek(offset); + final int readLen = hasLength ? (int) length : (int) (fileSize - offset); + final byte[] data = new byte[readLen]; + raf.readFully(data); + return data; + } + } + + /** + * Reads a file as text with the given encoding. + * If fallback is true, malformed byte sequences and XML-illegal characters + * are replaced with U+FFFD. Otherwise, an IOException is thrown if the file + * contains malformed bytes or XML-illegal characters. + */ + private String readFileText(final Path path, final Charset encoding, final boolean fallback) throws IOException { + final String content; + if (fallback) { + final java.nio.charset.CharsetDecoder decoder = encoding.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE) + .replaceWith("\uFFFD"); + final byte[] bytes = Files.readAllBytes(path); + content = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + // Replace XML-illegal characters with U+FFFD + return replaceXmlIllegalChars(content); + } else { + content = Files.readString(path, encoding); + // Check for XML-illegal characters + checkXmlIllegalChars(content); + return content; + } + } + + /** + * Check if a string contains characters illegal in XML 1.0 and throw IOException if so. + * XML 1.0 allows: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + */ + private void checkXmlIllegalChars(final String text) throws IOException { + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if (c < 0x20 && c != 0x9 && c != 0xA && c != 0xD) { + throw new IOException("File contains XML-illegal character U+" + + String.format("%04X", (int) c) + " at position " + i); + } + if (c >= 0xFFFE) { + throw new IOException("File contains XML-illegal character U+" + + String.format("%04X", (int) c) + " at position " + i); + } + } + } + + /** + * Replace characters illegal in XML 1.0 with U+FFFD. + */ + private String replaceXmlIllegalChars(final String text) { + final StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if ((c < 0x20 && c != 0x9 && c != 0xA && c != 0xD) || c >= 0xFFFE) { + sb.append('\uFFFD'); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java new file mode 100644 index 00000000000..ab3546f7fcd --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java @@ -0,0 +1,521 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.*; + +/** + * EXPath File Module 4.0 - File/directory manipulation and listing functions. + *

+ * Implements: file:copy, file:move, file:delete, file:create-dir, file:create-temp-dir, + * file:create-temp-file, file:list, file:children, file:descendants + */ +public class FileManipulation extends BasicFunction { + + private static final FunctionParameterSequenceType PATH_PARAM = + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, "The file or directory path."); + + public static final FunctionSignature[] signatures = { + // file:copy($source, $target) + new FunctionSignature( + new QName("copy", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Copies a file or directory. If the target exists it is overwritten.", + new SequenceType[]{ + new FunctionParameterSequenceType("source", Type.STRING, Cardinality.EXACTLY_ONE, "Source path."), + new FunctionParameterSequenceType("target", Type.STRING, Cardinality.EXACTLY_ONE, "Target path.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:move($source, $target) + new FunctionSignature( + new QName("move", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Moves a file or directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("source", Type.STRING, Cardinality.EXACTLY_ONE, "Source path."), + new FunctionParameterSequenceType("target", Type.STRING, Cardinality.EXACTLY_ONE, "Target path.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:delete($path) + new FunctionSignature( + new QName("delete", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Deletes a file or empty directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:delete($path, $recursive) + new FunctionSignature( + new QName("delete", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Deletes a file or directory. If $recursive is true, non-empty directories are removed recursively.", + new SequenceType[]{ + PATH_PARAM, + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, + "If true, delete directories recursively. Default: false.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:create-dir($dir) + new FunctionSignature( + new QName("create-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a directory, including any necessary parent directories.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:create-temp-dir($prefix, $suffix) + new FunctionSignature( + new QName("create-temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary directory in the system default temp directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the directory name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the directory name.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary directory.") + ), + // file:create-temp-dir($prefix, $suffix, $dir) + new FunctionSignature( + new QName("create-temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the directory name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the directory name."), + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.ZERO_OR_ONE, "The parent directory. Default: system temp dir.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary directory.") + ), + // file:create-temp-file($prefix, $suffix) + new FunctionSignature( + new QName("create-temp-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary file in the system default temp directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the file name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the file name.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary file.") + ), + // file:create-temp-file($prefix, $suffix, $dir) + new FunctionSignature( + new QName("create-temp-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary file.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the file name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the file name."), + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.ZERO_OR_ONE, "The parent directory. Default: system temp dir.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary file.") + ), + // file:list($dir) + new FunctionSignature( + new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Lists the contents of a directory as relative paths.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of directory contents.") + ), + // file:list($dir, $recursive) + new FunctionSignature( + new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Lists the contents of a directory, optionally recursively.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path."), + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, "If true, list recursively. Default: false.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of directory contents.") + ), + // file:list($dir, $recursive, $pattern) + new FunctionSignature( + new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Lists the contents of a directory matching a glob pattern.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path."), + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, "If true, list recursively. Default: false."), + new FunctionParameterSequenceType("pattern", Type.STRING, Cardinality.ZERO_OR_ONE, "A glob pattern to filter results.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of matching directory contents.") + ), + // file:children($path) + new FunctionSignature( + new QName("children", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the paths of immediate children of a directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The absolute paths of children.") + ), + // file:descendants($path) + new FunctionSignature( + new QName("descendants", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the paths of all descendants of a directory recursively.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The absolute paths of all descendants.") + ), + // file:list-roots() + new FunctionSignature( + new QName("list-roots", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the root directories of the file system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The root directories.") + ) + }; + + public FileManipulation(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + if (isCalledAs("list-roots")) { + return listRoots(); + } + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (isCalledAs("copy")) { + return copy(path, args); + } else if (isCalledAs("move")) { + return move(path, args); + } else if (isCalledAs("delete")) { + return delete(path, args); + } else if (isCalledAs("create-dir")) { + return createDir(path); + } else if (isCalledAs("create-temp-dir")) { + return createTempDir(args); + } else if (isCalledAs("create-temp-file")) { + return createTempFile(args); + } else if (isCalledAs("list")) { + return list(path, args); + } else if (isCalledAs("children")) { + return children(path); + } else if (isCalledAs("descendants")) { + return descendants(path); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence copy(final Path source, final Sequence[] args) throws XPathException { + if (!Files.exists(source)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Source does not exist: " + source.toAbsolutePath()); + } + final Path target = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context); + + // Check target parent directory exists + final Path targetParent = target.toAbsolutePath().getParent(); + if (targetParent != null && !Files.isDirectory(targetParent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Target parent directory does not exist: " + targetParent); + } + + try { + if (Files.isDirectory(source)) { + copyDirectory(source, target); + } else { + // If target is an existing directory, copy into it + final Path actualTarget = Files.isDirectory(target) ? target.resolve(source.getFileName()) : target; + Files.copy(source, actualTarget, StandardCopyOption.REPLACE_EXISTING); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void copyDirectory(final Path source, final Path target) throws IOException { + Files.walkFileTree(source, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException { + final Path targetDir = target.resolve(source.relativize(dir)); + Files.createDirectories(targetDir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { + Files.copy(file, target.resolve(source.relativize(file)), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } + + private Sequence move(final Path source, final Sequence[] args) throws XPathException { + if (!Files.exists(source)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Source does not exist: " + source.toAbsolutePath()); + } + final Path target = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context); + + // Check target parent directory exists + final Path targetParent = target.toAbsolutePath().getParent(); + if (targetParent != null && !Files.isDirectory(targetParent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Target parent directory does not exist: " + targetParent); + } + + try { + // If target is an existing directory, move into it + final Path actualTarget = Files.isDirectory(target) ? target.resolve(source.getFileName()) : target; + Files.move(source, actualTarget, StandardCopyOption.REPLACE_EXISTING); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence delete(final Path path, final Sequence[] args) throws XPathException { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + final boolean recursive = args.length > 1 && !args[1].isEmpty() + && args[1].itemAt(0).toJavaObject(Boolean.class); + + try { + if (Files.isDirectory(path)) { + if (recursive) { + try (final Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.delete(p); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } else { + // Attempt to delete; will fail if non-empty + try { + Files.delete(path); + } catch (final DirectoryNotEmptyException e) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Directory is not empty (use $recursive = true()): " + path.toAbsolutePath()); + } + } + } else { + Files.delete(path); + } + } catch (final UncheckedIOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getCause().getMessage()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence createDir(final Path path) throws XPathException { + // Check if the path itself or any ancestor is an existing non-directory file + Path check = path.toAbsolutePath().normalize(); + while (check != null) { + if (Files.exists(check) && !Files.isDirectory(check)) { + throw new XPathException(this, ExpathFileErrorCode.EXISTS, + "Path exists and is not a directory: " + check); + } + check = check.getParent(); + } + try { + Files.createDirectories(path); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence createTempDir(final Sequence[] args) throws XPathException { + final String prefix = args.length > 0 && !args[0].isEmpty() ? args[0].getStringValue() : ""; + final String suffix = args.length > 1 && !args[1].isEmpty() ? args[1].getStringValue() : ""; + final Path dir = args.length > 2 && !args[2].isEmpty() + ? ExpathFileModuleHelper.getPath(args[2].getStringValue(), this, context) + : Paths.get(System.getProperty("java.io.tmpdir")); + + if (!Files.isDirectory(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent is not a directory: " + dir.toAbsolutePath()); + } + + try { + // Java's createTempDirectory only supports prefix, so we append suffix manually + final Path tempDir = Files.createTempDirectory(dir, prefix); + if (!suffix.isEmpty()) { + final Path renamed = tempDir.resolveSibling(tempDir.getFileName().toString() + suffix); + Files.move(tempDir, renamed); + return new StringValue(this, renamed.toAbsolutePath().toString() + File.separator); + } + return new StringValue(this, tempDir.toAbsolutePath().toString() + File.separator); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence createTempFile(final Sequence[] args) throws XPathException { + final String prefix = args.length > 0 && !args[0].isEmpty() ? args[0].getStringValue() : ""; + final String suffix = args.length > 1 && !args[1].isEmpty() ? args[1].getStringValue() : ""; + final Path dir = args.length > 2 && !args[2].isEmpty() + ? ExpathFileModuleHelper.getPath(args[2].getStringValue(), this, context) + : Paths.get(System.getProperty("java.io.tmpdir")); + + if (!Files.isDirectory(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent is not a directory: " + dir.toAbsolutePath()); + } + + try { + final Path tempFile = Files.createTempFile(dir, prefix, suffix.isEmpty() ? null : suffix); + return new StringValue(this, tempFile.toAbsolutePath().toString()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + @SuppressWarnings("PMD.NPathComplexity") + private Sequence list(final Path dir, final Sequence[] args) throws XPathException { + if (!Files.exists(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Directory does not exist: " + dir.toAbsolutePath()); + } + if (!Files.isDirectory(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Path is not a directory: " + dir.toAbsolutePath()); + } + + final boolean recursive = args.length > 1 && !args[1].isEmpty() + && args[1].itemAt(0).toJavaObject(Boolean.class); + final String pattern = args.length > 2 && !args[2].isEmpty() + ? args[2].getStringValue() : null; + + final Pattern regex = pattern != null ? globToRegex(pattern) : null; + + try { + final ValueSequence result = new ValueSequence(); + try (final Stream stream = recursive ? Files.walk(dir) : Files.list(dir)) { + stream.filter(p -> !p.equals(dir)) + .forEach(p -> { + final String relative = dir.relativize(p).toString(); + final String entry = Files.isDirectory(p) + ? relative + File.separator : relative; + if (regex == null || regex.matcher(entry.replace(File.separator, "/")).matches() + || regex.matcher(p.getFileName().toString()).matches()) { + result.add(new StringValue(this, entry)); + } + }); + } + return result; + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence children(final Path path) throws XPathException { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + if (!Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Path is not a directory: " + path.toAbsolutePath()); + } + + try { + final ValueSequence result = new ValueSequence(); + try (final Stream stream = Files.list(path)) { + stream.forEach(p -> { + final String absPath = p.toAbsolutePath().toString(); + final String entry = Files.isDirectory(p) ? absPath + File.separator : absPath; + result.add(new StringValue(this, entry)); + }); + } + return result; + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence descendants(final Path path) throws XPathException { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + if (!Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Path is not a directory: " + path.toAbsolutePath()); + } + + try { + final ValueSequence result = new ValueSequence(); + try (final Stream walk = Files.walk(path)) { + walk.filter(p -> !p.equals(path)) + .forEach(p -> { + final String absPath = p.toAbsolutePath().toString(); + final String entry = Files.isDirectory(p) ? absPath + File.separator : absPath; + result.add(new StringValue(this, entry)); + }); + } + return result; + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence listRoots() { + final ValueSequence result = new ValueSequence(); + for (final File root : File.listRoots()) { + result.add(new StringValue(this, root.getAbsolutePath())); + } + return result; + } + + /** + * Convert a simple glob pattern (with * and ?) to a Java regex Pattern. + */ + private static Pattern globToRegex(final String glob) { + final StringBuilder regex = new StringBuilder(); + for (int i = 0; i < glob.length(); i++) { + final char c = glob.charAt(i); + switch (c) { + case '*' -> regex.append(".*"); + case '?' -> regex.append('.'); + case '.', '(', ')', '[', ']', '{', '}', '\\', '^', '$', '|', '+' -> + regex.append('\\').append(c); + default -> regex.append(c); + } + } + return Pattern.compile(regex.toString()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java new file mode 100644 index 00000000000..0d06fdd3467 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java @@ -0,0 +1,139 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.*; + +/** + * EXPath File Module 4.0 - Path functions. + *

+ * Implements: file:name, file:parent, file:path-to-native, file:path-to-uri, file:resolve-path + */ +public class FilePaths extends BasicFunction { + + private static final FunctionParameterSequenceType PATH_PARAM = + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, "The file path."); + + public static final FunctionSignature[] signatures = { + // file:name($path as xs:string) as xs:string + new FunctionSignature( + new QName("name", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the name of a file or directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file or directory name.") + ), + // file:parent($path as xs:string) as xs:string? + new FunctionSignature( + new QName("parent", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the parent directory of a path.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "the parent directory path, or empty for root.") + ), + // file:path-to-native($path as xs:string) as xs:string + new FunctionSignature( + new QName("path-to-native", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the native, canonical path.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the native path.") + ), + // file:path-to-uri($path as xs:string) as xs:anyURI + new FunctionSignature( + new QName("path-to-uri", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the path as a file:// URI.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.ANY_URI, Cardinality.EXACTLY_ONE, "the file:// URI.") + ), + // file:resolve-path($path as xs:string) as xs:string + new FunctionSignature( + new QName("resolve-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Resolves a relative path against the current working directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the resolved absolute path.") + ), + // file:resolve-path($path as xs:string, $base as xs:string) as xs:string + new FunctionSignature( + new QName("resolve-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Resolves a relative path against a base directory.", + new SequenceType[]{ + PATH_PARAM, + new FunctionParameterSequenceType("base", Type.STRING, Cardinality.ZERO_OR_ONE, "The base directory to resolve against.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the resolved absolute path.") + ) + }; + + public FilePaths(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (isCalledAs("name")) { + final Path fileName = path.getFileName(); + return new StringValue(this, fileName != null ? fileName.toString() : ""); + } else if (isCalledAs("parent")) { + final Path absPath = path.toAbsolutePath().normalize(); + final Path parent = absPath.getParent(); + if (parent == null) { + return Sequence.EMPTY_SEQUENCE; + } + return new StringValue(this, parent.toString() + File.separator); + } else if (isCalledAs("path-to-native")) { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + try { + return new StringValue(this, path.toRealPath().toString()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } else if (isCalledAs("path-to-uri")) { + final Path abs = path.toAbsolutePath().normalize(); + return new AnyURIValue(this, abs.toUri().toString()); + } else if (isCalledAs("resolve-path")) { + if (args.length > 1 && !args[1].isEmpty()) { + final Path base = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context); + return new StringValue(this, base.resolve(path).toAbsolutePath().normalize().toString()); + } + return new StringValue(this, path.toAbsolutePath().normalize().toString()); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java new file mode 100644 index 00000000000..e612a24a9a0 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java @@ -0,0 +1,184 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Date; +import java.util.stream.Stream; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.BooleanValue; +import org.exist.xquery.value.DateTimeValue; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.Type; + +/** + * EXPath File Module 4.0 - File Properties functions. + *

+ * Implements: file:exists, file:is-dir, file:is-file, file:is-absolute, file:last-modified, file:size + */ +public class FileProperties extends BasicFunction { + + private static final FunctionParameterSequenceType PATH_PARAM = + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, + "The file path."); + + public static final FunctionSignature[] signatures = { + // file:exists($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("exists", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path exists.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path exists.") + ), + // file:is-dir($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("is-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path points to a directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path is a directory.") + ), + // file:is-file($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("is-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path points to a regular file.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path is a regular file.") + ), + // file:is-absolute($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("is-absolute", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path is absolute.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path is absolute.") + ), + // file:last-modified($path as xs:string) as xs:dateTime + new FunctionSignature( + new QName("last-modified", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the last modification time of a file or directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.DATE_TIME, Cardinality.EXACTLY_ONE, + "the last modification time.") + ), + // file:size($path as xs:string) as xs:integer + new FunctionSignature( + new QName("size", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the byte size of a file, or 0 for a directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE, + "the file size in bytes.") + ), + // file:size($path as xs:string, $recursive as xs:boolean) as xs:integer + new FunctionSignature( + new QName("size", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the byte size of a file, or for a directory the recursive size if $recursive is true.", + new SequenceType[]{ + PATH_PARAM, + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, + "If true and path is a directory, compute recursive size.") + }, + new FunctionReturnSequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE, + "the file or directory size in bytes.") + ) + }; + + public FileProperties(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (isCalledAs("exists")) { + return BooleanValue.valueOf(Files.exists(path)); + } else if (isCalledAs("is-dir")) { + return BooleanValue.valueOf(Files.isDirectory(path)); + } else if (isCalledAs("is-file")) { + return BooleanValue.valueOf(Files.isRegularFile(path)); + } else if (isCalledAs("is-absolute")) { + return BooleanValue.valueOf(path.isAbsolute()); + } else if (isCalledAs("last-modified")) { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + try { + final FileTime ft = Files.getLastModifiedTime(path); + return new DateTimeValue(this, new Date(ft.toMillis())); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } else if (isCalledAs("size")) { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + try { + if (Files.isDirectory(path)) { + final boolean recursive = args.length > 1 && !args[1].isEmpty() + && args[1].itemAt(0).toJavaObject(Boolean.class); + if (recursive) { + try (final Stream walk = Files.walk(path)) { + final long total = walk + .filter(Files::isRegularFile) + .mapToLong(p -> { + try { + return Files.size(p); + } catch (final IOException e) { + return 0L; + } + }) + .sum(); + return new IntegerValue(this, total); + } + } + return new IntegerValue(this, 0); + } + return new IntegerValue(this, Files.size(path)); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java new file mode 100644 index 00000000000..3f2b928a7ff --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java @@ -0,0 +1,143 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.File; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; + +/** + * EXPath File Module 4.0 - System property functions. + *

+ * Implements: file:dir-separator, file:line-separator, file:path-separator, + * file:temp-dir, file:base-dir, file:current-dir + */ +public class FileSystemProperties extends BasicFunction { + + public static final FunctionSignature[] signatures = { + // file:dir-separator() as xs:string + new FunctionSignature( + new QName("dir-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the directory separator used by the operating system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the directory separator.") + ), + // file:line-separator() as xs:string + new FunctionSignature( + new QName("line-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the line separator used by the operating system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the line separator.") + ), + // file:path-separator() as xs:string + new FunctionSignature( + new QName("path-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the path separator used by the operating system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the path separator.") + ), + // file:temp-dir() as xs:string + new FunctionSignature( + new QName("temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the path of the temporary directory.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the temporary directory path.") + ), + // file:base-dir() as xs:string? + new FunctionSignature( + new QName("base-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the base directory of the current query, or empty if not available.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "the base directory path.") + ), + // file:current-dir() as xs:string + new FunctionSignature( + new QName("current-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the current working directory.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the current working directory.") + ) + }; + + public FileSystemProperties(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + if (isCalledAs("dir-separator")) { + return new StringValue(this, File.separator); + } else if (isCalledAs("line-separator")) { + return new StringValue(this, System.lineSeparator()); + } else if (isCalledAs("path-separator")) { + return new StringValue(this, File.pathSeparator); + } else if (isCalledAs("temp-dir")) { + return new StringValue(this, System.getProperty("java.io.tmpdir") + File.separator); + } else if (isCalledAs("base-dir")) { + try { + final String baseURI = context.getBaseURI().getStringValue(); + if (baseURI != null && !baseURI.isEmpty() && baseURI.startsWith("file:")) { + final Path basePath = Paths.get(new URI(baseURI)); + final Path parent = basePath.getParent(); + if (parent != null) { + return new StringValue(this, parent.toString() + File.separator); + } + } + } catch (final Exception e) { + // Fall through to return empty + } + return Sequence.EMPTY_SEQUENCE; + } else if (isCalledAs("current-dir")) { + // If a file: base URI is set (e.g., sandpit), use its directory as the working directory + try { + final String baseURI = context.getBaseURI().getStringValue(); + if (baseURI != null && !baseURI.isEmpty() && baseURI.startsWith("file:")) { + final Path basePath = Paths.get(new URI(baseURI)); + final Path dir = java.nio.file.Files.isDirectory(basePath) ? basePath : basePath.getParent(); + if (dir != null) { + return new StringValue(this, dir.toString() + File.separator); + } + } + } catch (final Exception ignored) { + // Fall through to JVM CWD + } + return new StringValue(this, System.getProperty("user.dir") + File.separator); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java new file mode 100644 index 00000000000..1b24cae555a --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java @@ -0,0 +1,296 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import org.exist.dom.QName; +import org.exist.storage.serializers.Serializer; +import org.exist.util.serializer.SAXSerializer; +import org.exist.util.serializer.SerializerPool; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.AbstractMapType; +import org.exist.xquery.util.SerializerUtils; +import org.exist.xquery.value.*; +import org.xml.sax.SAXException; + +/** + * EXPath File Module 4.0 - Write functions. + *

+ * Implements: file:write, file:write-text, file:write-text-lines, file:write-binary + */ +public class FileWrite extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, "The path to the file."); + + public static final FunctionSignature[] signatures = { + // file:write($file, $value) + new FunctionSignature( + new QName("write", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a serialized sequence to a file. Creates the file if it does not exist, overwrites it otherwise.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write($file, $value, $options) + new FunctionSignature( + new QName("write", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a serialized sequence to a file with serialization options.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and write."), + new FunctionParameterSequenceType("options", Type.ITEM, Cardinality.ZERO_OR_ONE, "Serialization parameters as map(*) or element(output:serialization-parameters).") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text($file, $value) + new FunctionSignature( + new QName("write-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a string to a file. Creates the file if it does not exist, overwrites it otherwise.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text($file, $value, $encoding) + new FunctionSignature( + new QName("write-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a string to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to write."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text-lines($file, $values) + new FunctionSignature( + new QName("write-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a sequence of strings as lines to a file, separated by the platform line separator.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("values", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text-lines($file, $values, $encoding) + new FunctionSignature( + new QName("write-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a sequence of strings as lines to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("values", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to write."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-binary($file, $value) + new FunctionSignature( + new QName("write-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes binary data to a file. Creates the file if it does not exist, overwrites it otherwise.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-binary($file, $value, $offset) + new FunctionSignature( + new QName("write-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes binary data to a file at the given offset.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to write."), + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The byte offset at which to start writing. Default: 0.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ) + }; + + public FileWrite(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + checkParentDir(path); + + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("write")) { + return write(path, args); + } else if (isCalledAs("write-text")) { + return writeText(path, args); + } else if (isCalledAs("write-text-lines")) { + return writeTextLines(path, args); + } else if (isCalledAs("write-binary")) { + return writeBinary(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence write(final Path path, final Sequence[] args) throws XPathException { + final Sequence value = args[1]; + final Properties outputProperties = new Properties(); + if (args.length > 2 && !args[2].isEmpty()) { + final Item optionsItem = args[2].itemAt(0); + if (optionsItem instanceof AbstractMapType) { + outputProperties.putAll(SerializerUtils.getSerializationOptions(this, (AbstractMapType) optionsItem)); + } else if (optionsItem instanceof NodeValue) { + SerializerUtils.getSerializationOptions(this, (NodeValue) optionsItem, outputProperties); + } + } + + final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class); + try { + final Serializer serializer = context.getBroker().borrowSerializer(); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path)), StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + serializer.setProperties(outputProperties); + serializer.setSAXHandlers(sax, sax); + + for (final SequenceIterator i = value.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (Type.subTypeOf(item.getType(), Type.NODE)) { + serializer.toSAX((NodeValue) item); + } else { + writer.write(item.getStringValue()); + } + } + } finally { + context.getBroker().returnSerializer(serializer); + } + } catch (final IOException | SAXException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } finally { + SerializerPool.getInstance().returnObject(sax); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence writeText(final Path path, final Sequence[] args) throws XPathException { + final String text = args[1].getStringValue(); + final Charset encoding = getEncoding(args, 2); + try { + Files.writeString(path, text, encoding); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence writeTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path)), encoding)) { + final String lineSep = System.lineSeparator(); + for (final SequenceIterator i = args[1].iterate(); i.hasNext(); ) { + writer.write(i.nextItem().getStringValue()); + writer.write(lineSep); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence writeBinary(final Path path, final Sequence[] args) throws XPathException { + final BinaryValue binaryValue = (BinaryValue) args[1].itemAt(0); + final long offset = args.length > 2 && !args[2].isEmpty() ? args[2].itemAt(0).toJavaObject(Long.class) : 0; + + try { + if (offset == 0) { + try (final OutputStream os = Files.newOutputStream(path); + final InputStream is = binaryValue.getInputStream()) { + is.transferTo(os); + } + } else { + if (offset < 0) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset must not be negative: " + offset); + } + if (Files.exists(path)) { + final long fileSize = Files.size(path); + if (offset > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset " + offset + " exceeds file size " + fileSize); + } + } + try (final RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw"); + final InputStream is = binaryValue.getInputStream()) { + raf.seek(offset); + is.transferTo(new OutputStream() { + @Override + public void write(int b) throws IOException { + raf.write(b); + } + @Override + public void write(byte[] b, int off, int len) throws IOException { + raf.write(b, off, len); + } + }); + } + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void checkParentDir(final Path path) throws XPathException { + final Path parent = path.getParent(); + if (parent != null && !Files.isDirectory(parent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent directory does not exist: " + parent.toAbsolutePath()); + } + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml b/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml index 399137a7230..625c11b567b 100644 --- a/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml +++ b/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml @@ -750,6 +750,7 @@ + diff --git a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml index 0203297b9dd..8d39b4beb96 100644 --- a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml +++ b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml @@ -760,6 +760,7 @@ + diff --git a/extensions/modules/file/src/test/resources-filtered/conf.xml b/extensions/modules/file/src/test/resources-filtered/conf.xml index 11c020c728e..1d0c7cb8d3f 100644 --- a/extensions/modules/file/src/test/resources-filtered/conf.xml +++ b/extensions/modules/file/src/test/resources-filtered/conf.xml @@ -757,6 +757,7 @@ + diff --git a/extensions/modules/image/src/test/resources-filtered/conf.xml b/extensions/modules/image/src/test/resources-filtered/conf.xml index 9df613700e8..9a217439ba7 100644 --- a/extensions/modules/image/src/test/resources-filtered/conf.xml +++ b/extensions/modules/image/src/test/resources-filtered/conf.xml @@ -760,6 +760,7 @@ + diff --git a/extensions/modules/mail/src/test/resources-filtered/conf.xml b/extensions/modules/mail/src/test/resources-filtered/conf.xml index cfebd73a39d..ae4139f6b81 100644 --- a/extensions/modules/mail/src/test/resources-filtered/conf.xml +++ b/extensions/modules/mail/src/test/resources-filtered/conf.xml @@ -749,6 +749,7 @@ + diff --git a/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml b/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml index 6850c1477fe..a823aa34a07 100644 --- a/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml +++ b/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml @@ -753,6 +753,7 @@ + diff --git a/extensions/modules/pom.xml b/extensions/modules/pom.xml index 0f8bf723555..ad43dda15f1 100644 --- a/extensions/modules/pom.xml +++ b/extensions/modules/pom.xml @@ -54,6 +54,7 @@ exi expathrepo expathrepo/expathrepo-trigger-test + expath-file file image jndi diff --git a/extensions/modules/sql/src/test/resources-filtered/conf.xml b/extensions/modules/sql/src/test/resources-filtered/conf.xml index 09ba6545e1e..f7c3b0637a8 100644 --- a/extensions/modules/sql/src/test/resources-filtered/conf.xml +++ b/extensions/modules/sql/src/test/resources-filtered/conf.xml @@ -753,6 +753,7 @@ + diff --git a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml index a1a95c324d6..bcda6a2f900 100644 --- a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml +++ b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml @@ -757,6 +757,7 @@ + diff --git a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml index 3e14e631740..f6e2b634c3c 100644 --- a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml +++ b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml @@ -759,6 +759,7 @@ + diff --git a/extensions/webdav/src/test/resources-filtered/conf.xml b/extensions/webdav/src/test/resources-filtered/conf.xml index 5dc0efc380a..7fd8a40007e 100644 --- a/extensions/webdav/src/test/resources-filtered/conf.xml +++ b/extensions/webdav/src/test/resources-filtered/conf.xml @@ -743,6 +743,7 @@ + diff --git a/extensions/xqdoc/src/test/resources-filtered/conf.xml b/extensions/xqdoc/src/test/resources-filtered/conf.xml index 7c96ef98809..56ba1a64440 100644 --- a/extensions/xqdoc/src/test/resources-filtered/conf.xml +++ b/extensions/xqdoc/src/test/resources-filtered/conf.xml @@ -759,6 +759,7 @@ +