From cef8fd5a8e61778dfc4ea45edf9cbd2840297520 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 24 Apr 2026 15:59:29 -0400 Subject: [PATCH 1/3] [feature] Add EXPath File Module 4.0 as built-in extension module Port the EXPath File Module (http://expath.org/ns/file) from a standalone XAR package (eXist-db/exist-file) to a built-in extension module. This makes the module available out of the box without requiring XAR installation. The EXPath File Module implements the EXPath File Module 4.0 specification and coexists with eXist's native file module (http://exist-db.org/xquery/file) since they use different namespace URIs. Source: https://github.com/eXist-db/exist-file Co-Authored-By: Claude Opus 4.6 (1M context) --- exist-distribution/pom.xml | 6 + exist-distribution/src/main/config/conf.xml | 1 + extensions/modules/expath-file/pom.xml | 57 ++ .../file/expath/ExpathFileErrorCode.java | 73 +++ .../modules/file/expath/ExpathFileModule.java | 148 +++++ .../file/expath/ExpathFileModuleHelper.java | 137 +++++ .../modules/file/expath/FileAppend.java | 256 +++++++++ .../xquery/modules/file/expath/FileIO.java | 323 +++++++++++ .../modules/file/expath/FileManipulation.java | 538 ++++++++++++++++++ .../xquery/modules/file/expath/FilePaths.java | 139 +++++ .../modules/file/expath/FileProperties.java | 184 ++++++ .../file/expath/FileSystemProperties.java | 143 +++++ .../xquery/modules/file/expath/FileWrite.java | 296 ++++++++++ extensions/modules/pom.xml | 1 + 14 files changed, 2302 insertions(+) create mode 100644 extensions/modules/expath-file/pom.xml create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java create mode 100644 extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java 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/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..dad29cb3f1a --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java @@ -0,0 +1,538 @@ +/* + * 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(".*"); + break; + case '?': + regex.append('.'); + break; + case '.': + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '\\': + case '^': + case '$': + case '|': + case '+': + regex.append('\\').append(c); + break; + 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/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 From 9c612417e2633d5bc62f3f92de7a44c2ade92579 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 24 Apr 2026 18:47:12 -0400 Subject: [PATCH 2/3] [bugfix] Register EXPath File module in all test conf.xml files The module was only registered in the distribution conf.xml but not in any test conf.xml files. The XQTS runner uses the test conf.xml, so all EXPath File tests fail with "unknown function" errors without this. Co-Authored-By: Claude Opus 4.6 (1M context) --- exist-ant/src/test/resources-filtered/conf.xml | 1 + exist-core/src/test/resources-filtered/conf.xml | 1 + .../resources-filtered/org/exist/storage/statistics/conf.xml | 1 + exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml | 1 + .../org/exist/xquery/functions/transform/conf.xml | 1 + .../contentextraction/src/test/resources-filtered/conf.xml | 1 + extensions/debuggee/src/test/resources-filtered/conf.xml | 1 + extensions/expath/src/test/resources-filtered/conf.xml | 1 + extensions/exquery/restxq/src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + extensions/indexes/lucene/src/test/resources-filtered/conf.xml | 1 + extensions/indexes/ngram/src/test/resources-filtered/conf.xml | 1 + extensions/indexes/range/src/test/resources-filtered/conf.xml | 1 + extensions/indexes/sort/src/test/resources-filtered/conf.xml | 1 + extensions/indexes/spatial/src/test/resources-filtered/conf.xml | 1 + .../indexes/vector-it/src/test/resources-filtered/conf.xml | 1 + extensions/modules/cache/src/test/resources-filtered/conf.xml | 1 + .../modules/compression/src/test/resources-filtered/conf.xml | 1 + extensions/modules/counter/src/test/resources-filtered/conf.xml | 1 + .../expathrepo-trigger-test/src/test/resources/conf.xml | 1 + .../modules/expathrepo/src/test/resources-filtered/conf.xml | 1 + extensions/modules/file/src/test/resources-filtered/conf.xml | 1 + extensions/modules/image/src/test/resources-filtered/conf.xml | 1 + extensions/modules/mail/src/test/resources-filtered/conf.xml | 1 + .../modules/persistentlogin/src/test/resources-filtered/conf.xml | 1 + extensions/modules/sql/src/test/resources-filtered/conf.xml | 1 + extensions/modules/xmldiff/src/test/resources-filtered/conf.xml | 1 + extensions/modules/xslfo/src/test/resources-filtered/conf.xml | 1 + extensions/webdav/src/test/resources-filtered/conf.xml | 1 + extensions/xqdoc/src/test/resources-filtered/conf.xml | 1 + 30 files changed, 30 insertions(+) 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/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/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/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 @@ + From 7c6a9ed96efb27d86663f72686a6361db386682a Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 12 May 2026 16:07:15 -0400 Subject: [PATCH 3/3] [refactor] FileManipulation.globToRegex: convert to switch expression Addresses reinhapa's review on PR #6257 (FileManipulation.java:511). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modules/file/expath/FileManipulation.java | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) 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 index dad29cb3f1a..ab3546f7fcd 100644 --- 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 @@ -509,28 +509,11 @@ private static Pattern globToRegex(final String glob) { for (int i = 0; i < glob.length(); i++) { final char c = glob.charAt(i); switch (c) { - case '*': - regex.append(".*"); - break; - case '?': - regex.append('.'); - break; - case '.': - case '(': - case ')': - case '[': - case ']': - case '{': - case '}': - case '\\': - case '^': - case '$': - case '|': - case '+': - regex.append('\\').append(c); - break; - default: - regex.append(c); + case '*' -> regex.append(".*"); + case '?' -> regex.append('.'); + case '.', '(', ')', '[', ']', '{', '}', '\\', '^', '$', '|', '+' -> + regex.append('\\').append(c); + default -> regex.append(c); } } return Pattern.compile(regex.toString());