diff --git a/exist-core/src/main/java/org/exist/xquery/functions/util/FileSync.java b/exist-core/src/main/java/org/exist/xquery/functions/util/FileSync.java new file mode 100644 index 00000000000..079f348885e --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/util/FileSync.java @@ -0,0 +1,583 @@ +/* + * 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.functions.util; + +import java.io.*; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.*; +import java.util.stream.Stream; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.*; +import javax.xml.transform.sax.SAXResult; +import javax.xml.transform.sax.SAXTransformerFactory; +import javax.xml.transform.sax.TransformerHandler; +import javax.xml.transform.stream.StreamSource; + +import org.apache.tools.ant.DirectoryScanner; +import org.exist.collections.Collection; +import org.exist.dom.persistent.BinaryDocument; +import org.exist.dom.persistent.DocumentImpl; +import org.exist.dom.QName; +import org.exist.dom.memtree.MemTreeBuilder; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.lock.Lock.LockMode; +import org.exist.storage.lock.ManagedLock; +import org.exist.storage.serializers.EXistOutputKeys; +import org.exist.storage.serializers.Serializer; +import org.exist.util.FileUtils; +import org.exist.util.LockException; +import org.exist.util.serializer.SAXSerializer; +import org.exist.util.serializer.SerializerPool; +import org.exist.xmldb.XmldbURI; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.ErrorCodes; +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.exist.xslt.TransformerFactoryAllocator; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; +import uk.ac.ic.doc.slurp.multilock.MultiLock; + +/** + * Synchronize a collection with a directory hierarchy. + * Relocated from the old file module (file:sync) to util:file-sync. + * This function is only available to the DBA role. + */ +public class FileSync extends BasicFunction { + + public static final String PRUNE_OPT = "prune"; + public static final String AFTER_OPT = "after"; + public static final String EXCLUDES_OPT = "excludes"; + + public static final QName SYNC_ELEMENT = new QName("sync", UtilModule.NAMESPACE_URI); + public static final QName UPDATE_ELEMENT = new QName("update", UtilModule.NAMESPACE_URI); + public static final QName DELETE_ELEMENT = new QName("delete", UtilModule.NAMESPACE_URI); + public static final QName ERROR_ELEMENT = new QName("error", UtilModule.NAMESPACE_URI); + + public static final QName COLLECTION_ATTR = new QName("collection", UtilModule.NAMESPACE_URI); + public static final QName DIR_ATTR = new QName("dir", UtilModule.NAMESPACE_URI); + + public static final QName FILE_ATTRIBUTE = new QName("file", XMLConstants.NULL_NS_URI); + public static final QName NAME_ATTRIBUTE = new QName("name", XMLConstants.NULL_NS_URI); + public static final QName COLLECTION_ATTRIBUTE = new QName("collection", XMLConstants.NULL_NS_URI); + public static final QName TYPE_ATTRIBUTE = new QName("type", XMLConstants.NULL_NS_URI); + public static final QName MODIFIED_ATTRIBUTE = new QName("modified", XMLConstants.NULL_NS_URI); + + public static final FunctionSignature signature = + new FunctionSignature( + new QName("file-sync", UtilModule.NAMESPACE_URI, UtilModule.PREFIX), + "Synchronize a collection with a directory hierarchy. " + + "This method is only available to the DBA role.", + new SequenceType[]{ + new FunctionParameterSequenceType("collection", Type.STRING, Cardinality.EXACTLY_ONE, + "Absolute path to the collection to synchronize to disk."), + new FunctionParameterSequenceType("targetPath", Type.ITEM, Cardinality.EXACTLY_ONE, + "The path or URI to the target directory. Relative paths resolve against EXIST_HOME."), + new FunctionParameterSequenceType("dateTimeOrOptionsMap", Type.ITEM, Cardinality.ZERO_OR_ONE, + "Options as map(*). The available settings are: " + + "\"" + PRUNE_OPT + "\": delete any file/dir that does not correspond to a doc/collection in the DB. " + + "\"" + AFTER_OPT + "\": only resources modified after this date will be taken into account. " + + "\"" + EXCLUDES_OPT + "\": files on the file system matching any of these patterns will be left untouched. " + + "(deprecated) If the third parameter is of type xs:dateTime, it is the same as setting the \"" + AFTER_OPT + "\" option.") + }, + new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.EXACTLY_ONE, + "A report (util:sync) which files and directories were updated (util:update) or deleted (util:delete).") + ); + + private static final Properties DEFAULT_PROPERTIES = new Properties(); + + static { + DEFAULT_PROPERTIES.put(OutputKeys.INDENT, "yes"); + DEFAULT_PROPERTIES.put(OutputKeys.OMIT_XML_DECLARATION, "no"); + DEFAULT_PROPERTIES.put(EXistOutputKeys.EXPAND_XINCLUDES, "no"); + DEFAULT_PROPERTIES.put(OutputKeys.ENCODING, "UTF-8"); + } + + private Properties outputProperties = new Properties(); + + public FileSync(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + + if (!context.getSubject().hasDbaRole()) { + throw new XPathException(this, "Function util:file-sync is only available to the DBA role"); + } + + final String collectionPath = args[0].getStringValue(); + final String target = args[1].getStringValue(); + final Map options = getOptions(args[2]); + + return startSync(target, collectionPath, options); + } + + private Map getOptions(final Sequence parameter) throws XPathException { + final Map options = new HashMap<>(); + options.put(AFTER_OPT, Sequence.EMPTY_SEQUENCE); + options.put(PRUNE_OPT, new BooleanValue(this, false)); + options.put(EXCLUDES_OPT, Sequence.EMPTY_SEQUENCE); + + if (parameter.isEmpty()) { + outputProperties = DEFAULT_PROPERTIES; + return options; + } + + final Item item = parameter.itemAt(0); + + if (item.getType() == Type.MAP_ITEM) { + final AbstractMapType optionsMap = (AbstractMapType) item; + + outputProperties = SerializerUtils.getSerializationOptions(this, optionsMap); + + for (String p : DEFAULT_PROPERTIES.stringPropertyNames()) { + if (optionsMap.get(new StringValue(this, p)).isEmpty()) { + outputProperties.setProperty(p, DEFAULT_PROPERTIES.getProperty(p)); + } + } + + final Sequence seq = optionsMap.get(new StringValue(this, EXCLUDES_OPT)); + if (!seq.isEmpty() && seq.getItemType() != Type.STRING) { + throw new XPathException(this, ErrorCodes.XPTY0004, + "Invalid value for option \"excludes\", expected xs:string* got " + + Type.getTypeName(seq.getItemType())); + } + options.put(EXCLUDES_OPT, seq); + + checkOption(optionsMap, PRUNE_OPT, Type.BOOLEAN, options); + checkOption(optionsMap, AFTER_OPT, Type.DATE_TIME, options); + } else if (parameter.itemAt(0).getType() == Type.DATE_TIME) { + options.put(AFTER_OPT, parameter); + } else { + throw new XPathException(this, ErrorCodes.XPTY0004, + "Invalid 3rd parameter, allowed parameter types are xs:dateTime or map(*) got " + Type.getTypeName(item.getType())); + } + return options; + } + + private void checkOption( + final AbstractMapType optionsMap, + final String name, + final int expectedType, + final Map options + ) throws XPathException { + final Sequence p = optionsMap.get(new StringValue(this, name)); + + if (p.isEmpty()) { + return; + } + + if (p.hasMany() || !Type.subTypeOf(p.getItemType(), expectedType)) { + throw new XPathException(this, ErrorCodes.XPTY0004, + "Invalid value type for option \"" + name + "\", expected " + + Type.getTypeName(expectedType) + " got " + + Type.getTypeName(p.itemAt(0).getType())); + } + + options.put(name, p); + } + + private Sequence startSync( + final String target, + final String collectionPath, + final Map options + ) throws XPathException { + final Date startDate = options.get(AFTER_OPT).hasOne() ? ((DateTimeValue) options.get(AFTER_OPT)).getDate() : null; + + final boolean prune = ((BooleanValue) options.get(PRUNE_OPT)).getValue(); + + final List excludes = new ArrayList<>(Collections.emptyList()); + for (final SequenceIterator si = options.get(EXCLUDES_OPT).iterate(); si.hasNext(); ) { + excludes.add(si.nextItem().getStringValue()); + } + + final Path p = getFile(target); + context.pushDocumentContext(); + final MemTreeBuilder output = context.getDocumentBuilder(); + final Path targetDir; + try { + if (p.isAbsolute()) { + targetDir = p; + } else { + final Optional home = context.getBroker().getConfiguration().getExistHome(); + targetDir = FileUtils.resolve(home, target); + } + + output.startDocument(); + output.startElement(SYNC_ELEMENT, null); + output.addAttribute(COLLECTION_ATTR, collectionPath); + output.addAttribute(DIR_ATTR, targetDir.toAbsolutePath().toString()); + + final String rootTargetAbsPath = targetDir.toAbsolutePath().toString(); + final String separator = rootTargetAbsPath.endsWith(File.separator) ? "" : File.separator; + syncCollection(XmldbURI.create(collectionPath), rootTargetAbsPath + separator, targetDir, startDate, prune, excludes, output); + + output.endElement(); + output.endDocument(); + } catch (final PermissionDeniedException | LockException e) { + throw new XPathException(this, e); + } finally { + context.popDocumentContext(); + } + return output.getDocument(); + } + + private Path getFile(final String path) throws XPathException { + if (path.startsWith("file:")) { + try { + return Paths.get(new URI(path)); + } catch (final Exception ex) { + throw new XPathException(this, path + " is not a valid URI: '" + ex.getMessage() + "'"); + } + } else { + return Paths.get(path); + } + } + + private void syncCollection( + final XmldbURI collectionPath, + final String rootTargetAbsPath, + final Path targetDir, + final Date startDate, + final boolean prune, + final List excludes, + final MemTreeBuilder output + ) throws PermissionDeniedException, LockException { + final Path targetDirectory; + try { + targetDirectory = Files.createDirectories(targetDir); + } catch (final IOException ioe) { + reportError(output, "Failed to create output directory: " + targetDir.toAbsolutePath() + + " for collection " + collectionPath); + return; + } + + if (!Files.isWritable(targetDirectory)) { + reportError(output, "Failed to write to output directory: " + targetDirectory.toAbsolutePath()); + return; + } + + final List subCollections = handleCollection(collectionPath, rootTargetAbsPath, targetDirectory, startDate, prune, excludes, output); + + for (final XmldbURI childURI : subCollections) { + final Path childDir = targetDirectory.resolve(childURI.lastSegment().toString()); + syncCollection(collectionPath.append(childURI), rootTargetAbsPath, childDir, startDate, prune, excludes, output); + } + } + + private List handleCollection( + final XmldbURI collectionPath, + final String rootTargetAbsPath, + final Path targetDirectory, + final Date startDate, + final boolean prune, + final List excludes, + final MemTreeBuilder output + ) throws PermissionDeniedException, LockException { + try (final Collection collection = context.getBroker().openCollection(collectionPath, LockMode.READ_LOCK)) { + if (collection == null) { + reportError(output, "Collection not found: " + collectionPath); + return Collections.emptyList(); + } + + if (prune) { + pruneCollectionEntries(collection, rootTargetAbsPath, targetDirectory, excludes, output); + } + + for (final Iterator i = collection.iterator(context.getBroker()); i.hasNext(); ) { + final DocumentImpl doc = i.next(); + final Path targetFile = targetDirectory.resolve(doc.getFileURI().toASCIIString()); + saveFile(targetFile, doc, startDate, output); + } + + final List subCollections = new ArrayList<>(collection.getChildCollectionCount(context.getBroker())); + for (final Iterator i = collection.collectionIterator(context.getBroker()); i.hasNext(); ) { + subCollections.add(i.next()); + } + return subCollections; + } + } + + private void pruneCollectionEntries( + final Collection collection, + final String rootTargetAbsPath, + final Path targetDir, + final List excludes, + final MemTreeBuilder output) { + try (final Stream fileStream = Files.walk(targetDir, 1)) { + fileStream.forEach(path -> { + try { + if (rootTargetAbsPath.startsWith(path.toString())) { + return; + } + + if (isExcludedPath(rootTargetAbsPath, path, excludes)) { + return; + } + + final String fileName = path.getFileName().toString(); + final XmldbURI dbname = XmldbURI.xmldbUriFor(fileName); + final String currentCollection = collection.getURI().getCollectionPath(); + + if (collection.hasDocument(context.getBroker(), dbname) + || collection.hasChildCollection(context.getBroker(), dbname) + || currentCollection.endsWith("/" + fileName)) { + return; + } + + if (Files.isDirectory(path)) { + deleteWithExcludes(rootTargetAbsPath, path, excludes, output); + } else { + Files.deleteIfExists(path); + output.startElement(DELETE_ELEMENT, null); + output.addAttribute(FILE_ATTRIBUTE, path.toAbsolutePath().toString()); + output.addAttribute(NAME_ATTRIBUTE, fileName); + output.endElement(); + } + + } catch (final IOException | java.net.URISyntaxException + | PermissionDeniedException | LockException e) { + reportError(output, e.getMessage()); + } + }); + } catch (final IOException e) { + reportError(output, e.getMessage()); + } + } + + private void saveFile(final Path targetFile, final DocumentImpl doc, final Date startDate, final MemTreeBuilder output) throws LockException { + if (startDate != null && doc.getLastModified() <= startDate.getTime()) { + return; + } + try (final ManagedLock lock = context.getBroker().getBrokerPool().getLockManager().acquireDocumentReadLock(doc.getURI())) { + if (Files.exists(targetFile) && Files.getLastModifiedTime(targetFile).compareTo(FileTime.fromMillis(doc.getLastModified())) >= 0) { + return; + } + + output.startElement(UPDATE_ELEMENT, null); + output.addAttribute(FILE_ATTRIBUTE, targetFile.toAbsolutePath().toString()); + output.addAttribute(NAME_ATTRIBUTE, doc.getFileURI().toString()); + output.addAttribute(COLLECTION_ATTRIBUTE, doc.getCollection().getURI().toString()); + output.addAttribute(MODIFIED_ATTRIBUTE, new DateTimeValue(this, new Date(doc.getLastModified())).getStringValue()); + + if (doc.getResourceType() == DocumentImpl.BINARY_FILE) { + output.addAttribute(TYPE_ATTRIBUTE, "binary"); + output.endElement(); + saveBinary(targetFile, (BinaryDocument) doc, output); + } else { + output.addAttribute(TYPE_ATTRIBUTE, "xml"); + output.endElement(); + saveXML(targetFile, doc, output); + } + } catch (final XPathException e) { + reportError(output, e.getMessage()); + } catch (final IOException e) { + reportError(output, "IO error while saving file: " + targetFile.toAbsolutePath()); + } + } + + private void saveXML(final Path targetFile, final DocumentImpl doc, final MemTreeBuilder output) throws IOException { + final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class); + try { + final boolean isRepoXML = Files.exists(targetFile) && "repo.xml".equals(FileUtils.fileName(targetFile)); + + if (isRepoXML) { + processRepoDesc(targetFile, doc, sax, output); + } else { + final Serializer serializer = context.getBroker().borrowSerializer(); + try (final Writer writer = new OutputStreamWriter(new BufferedOutputStream(Files.newOutputStream(targetFile)), StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + serializer.setProperties(outputProperties); + + serializer.setSAXHandlers(sax, sax); + serializer.toSAX(doc); + } finally { + context.getBroker().returnSerializer(serializer); + } + } + } catch (final SAXException e) { + reportError(output, "SAX exception while saving file " + targetFile.toAbsolutePath() + ": " + e.getMessage()); + } finally { + SerializerPool.getInstance().returnObject(sax); + } + } + + private void processRepoDesc(final Path targetFile, final DocumentImpl doc, final SAXSerializer sax, final MemTreeBuilder output) { + try { + final DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + final Document original = builder.parse(targetFile.toFile()); + + final Serializer serializer = context.getBroker().borrowSerializer(); + + try (final Writer writer = new OutputStreamWriter(new BufferedOutputStream(Files.newOutputStream(targetFile)), StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + + final StreamSource styleSource = new StreamSource(FileSync.class.getResourceAsStream("repo.xsl")); + + final SAXTransformerFactory factory = TransformerFactoryAllocator.getTransformerFactory(context.getBroker().getBrokerPool()); + final TransformerHandler handler = factory.newTransformerHandler(styleSource); + handler.getTransformer().setParameter("original", original.getDocumentElement()); + handler.setResult(new SAXResult(sax)); + + serializer.reset(); + serializer.setProperties(outputProperties); + serializer.setSAXHandlers(handler, handler); + + serializer.toSAX(doc); + } finally { + context.getBroker().returnSerializer(serializer); + } + } catch (final ParserConfigurationException e) { + reportError(output, "Parser exception while saving file " + targetFile.toAbsolutePath() + ": " + e.getMessage()); + } catch (final SAXException e) { + reportError(output, "SAX exception while saving file " + targetFile.toAbsolutePath() + ": " + e.getMessage()); + } catch (final IOException e) { + reportError(output, "IO exception while saving file " + targetFile.toAbsolutePath() + ": " + e.getMessage()); + } catch (final TransformerException e) { + reportError(output, "Transformation exception while saving file " + targetFile.toAbsolutePath() + ": " + e.getMessage()); + } + } + + private void saveBinary(final Path targetFile, final BinaryDocument binary, final MemTreeBuilder output) { + try (final InputStream is = context.getBroker().getBinaryResource(binary)) { + Files.copy(is, targetFile, StandardCopyOption.REPLACE_EXISTING); + } catch (final Exception e) { + reportError(output, e.getMessage()); + } + } + + private void reportError(final MemTreeBuilder output, final String msg) { + output.startElement(ERROR_ELEMENT, null); + output.characters(msg); + output.endElement(); + } + + private static boolean isExcludedPath(final String rootTargetAbsPath, final Path path, final List excludes) { + if (excludes.isEmpty()) { + return false; + } + if (rootTargetAbsPath.startsWith(path.toString())) { + return false; + } + + final String absPath = path.toAbsolutePath().toString(); + final String relPath = absPath.substring(rootTargetAbsPath.length()); + final String normalizedPath = relPath.startsWith(File.separator) + ? relPath.substring(File.separator.length()) + : relPath; + + return matchAny(excludes, normalizedPath); + } + + public static boolean matchAny(final Iterable patterns, final String path) { + for (final String pattern : patterns) { + if (DirectoryScanner.match(pattern, path)) { + return true; + } + } + return false; + } + + private static void deleteWithExcludes(final String root, final Path path, final List excludes, final MemTreeBuilder output) throws IOException { + if (Files.isDirectory(path)) { + Files.walkFileTree(path, new DeleteDirWithExcludesVisitor(root, excludes, output)); + } else { + Files.deleteIfExists(path); + } + } + + private static class DeleteDirWithExcludesVisitor extends SimpleFileVisitor { + + private final List excludes; + private final String root; + private final MemTreeBuilder output; + private boolean hasExcludedChildren = false; + + public DeleteDirWithExcludesVisitor(final String root, final List excludes, final MemTreeBuilder output) { + this.output = output; + this.excludes = excludes; + this.root = root; + } + + @Override + public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) { + if (isExcludedPath(root, dir, excludes)) { + hasExcludedChildren = true; + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { + if (isExcludedPath(root, file, excludes)) { + hasExcludedChildren = true; + return FileVisitResult.CONTINUE; + } + Files.deleteIfExists(file); + + output.startElement(DELETE_ELEMENT, null); + output.addAttribute(FILE_ATTRIBUTE, file.toAbsolutePath().toString()); + output.addAttribute(NAME_ATTRIBUTE, file.getFileName().toString()); + output.endElement(); + + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) throws IOException { + if (exc != null) { + throw exc; + } + if (hasExcludedChildren) { + return FileVisitResult.CONTINUE; + } + Files.deleteIfExists(dir); + + output.startElement(DELETE_ELEMENT, null); + output.addAttribute(FILE_ATTRIBUTE, dir.toAbsolutePath().toString()); + output.addAttribute(NAME_ATTRIBUTE, dir.getFileName().toString()); + output.endElement(); + + return FileVisitResult.CONTINUE; + } + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/util/UtilModule.java b/exist-core/src/main/java/org/exist/xquery/functions/util/UtilModule.java index b9e49a04b9b..a200850c4f0 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/util/UtilModule.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/util/UtilModule.java @@ -152,7 +152,8 @@ public class UtilModule extends AbstractInternalModule { new FunctionDef(Base64Functions.signatures[3], Base64Functions.class), new FunctionDef(BaseConversionFunctions.FNS_INT_TO_OCTAL, BaseConversionFunctions.class), new FunctionDef(BaseConversionFunctions.FNS_OCTAL_TO_INT, BaseConversionFunctions.class), - new FunctionDef(LineNumber.signature, LineNumber.class) + new FunctionDef(LineNumber.signature, LineNumber.class), + new FunctionDef(FileSync.signature, FileSync.class) }; static { diff --git a/exist-core/src/main/resources/org/exist/xquery/functions/util/repo.xsl b/exist-core/src/main/resources/org/exist/xquery/functions/util/repo.xsl new file mode 100644 index 00000000000..73b08785f5c --- /dev/null +++ b/exist-core/src/main/resources/org/exist/xquery/functions/util/repo.xsl @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml index 026734ebf05..67261afc561 100644 --- a/exist-distribution/src/main/config/conf.xml +++ b/exist-distribution/src/main/config/conf.xml @@ -1049,6 +1049,7 @@ + diff --git a/extensions/expath/src/main/java/org/expath/exist/ZipFileFunctions.java b/extensions/expath/src/main/java/org/expath/exist/ZipFileFunctions.java index f4093a61b64..f91f6e81165 100644 --- a/extensions/expath/src/main/java/org/expath/exist/ZipFileFunctions.java +++ b/extensions/expath/src/main/java/org/expath/exist/ZipFileFunctions.java @@ -53,8 +53,6 @@ public class ZipFileFunctions extends BasicFunction { private static final Logger logger = LogManager.getLogger(ZipFileFunctions.class); private final static FunctionParameterSequenceType HREF_PARAM = new FunctionParameterSequenceType("href", Type.ANY_URI, Cardinality.EXACTLY_ONE, "The URI for locating the Zip file"); - private final static FunctionParameterSequenceType ENTRY_PARAM = new FunctionParameterSequenceType("entry", Type.ELEMENT, Cardinality.EXACTLY_ONE, "A zip:entry element describing the contents of the file"); - private final static String FILE_ENTRIES = "entries"; private final static String ZIP_FILE = "zip-file"; private final static String UPDATE_ENTRIES = "update"; diff --git a/extensions/expath/src/main/java/org/expath/exist/file/ExpathFileErrorCode.java b/extensions/expath/src/main/java/org/expath/exist/file/ExpathFileErrorCode.java new file mode 100644 index 00000000000..38ba38e2cf1 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/ExpathFileModule.java b/extensions/expath/src/main/java/org/expath/exist/file/ExpathFileModule.java new file mode 100644 index 00000000000..c7bbbcb8277 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/ExpathFileModuleHelper.java b/extensions/expath/src/main/java/org/expath/exist/file/ExpathFileModuleHelper.java new file mode 100644 index 00000000000..ea59d882c31 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FileAppend.java b/extensions/expath/src/main/java/org/expath/exist/file/FileAppend.java new file mode 100644 index 00000000000..78d75f2b4eb --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FileIO.java b/extensions/expath/src/main/java/org/expath/exist/file/FileIO.java new file mode 100644 index 00000000000..39a2eb3a297 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FileManipulation.java b/extensions/expath/src/main/java/org/expath/exist/file/FileManipulation.java new file mode 100644 index 00000000000..d671ce40a13 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FilePaths.java b/extensions/expath/src/main/java/org/expath/exist/file/FilePaths.java new file mode 100644 index 00000000000..1d3baa9ded5 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FileProperties.java b/extensions/expath/src/main/java/org/expath/exist/file/FileProperties.java new file mode 100644 index 00000000000..6b794bd3a94 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FileSystemProperties.java b/extensions/expath/src/main/java/org/expath/exist/file/FileSystemProperties.java new file mode 100644 index 00000000000..735735cd638 --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/exist/file/FileWrite.java b/extensions/expath/src/main/java/org/expath/exist/file/FileWrite.java new file mode 100644 index 00000000000..d1fe234ebae --- /dev/null +++ b/extensions/expath/src/main/java/org/expath/exist/file/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.expath.exist.file; + +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/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java b/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java index a78a5eb87d4..5a6a464f867 100644 --- a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java +++ b/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java @@ -115,7 +115,7 @@ public Sequence getContent() { } return new EXistSequence(valueSequence, context); } catch(final XPathException xpe) { - throw new RuntimeException(xpe.getMessage(), xpe); + throw new IllegalStateException("Failed to build content sequence", xpe); } } @@ -153,13 +153,11 @@ public Iterable children(final String ns) { } @Override - public void noOtherNCNameAttribute(final String[] names, String[] forbidden_ns) throws ToolsException { - if ( forbidden_ns == null ) { - forbidden_ns = new String[] { }; - } + public void noOtherNCNameAttribute(final String[] names, final String[] forbidden_ns) throws ToolsException { + final String[] effectiveForbiddenNs = forbidden_ns == null ? new String[]{} : forbidden_ns; final String[] sorted_names = sortCopy(names); - final String[] sorted_ns = sortCopy(forbidden_ns); + final String[] sorted_ns = sortCopy(effectiveForbiddenNs); final NamedNodeMap attributes = element.getNode().getAttributes(); diff --git a/extensions/expath/src/test/java/org/expath/exist/file/ExpathFileTests.java b/extensions/expath/src/test/java/org/expath/exist/file/ExpathFileTests.java new file mode 100644 index 00000000000..15efee29342 --- /dev/null +++ b/extensions/expath/src/test/java/org/expath/exist/file/ExpathFileTests.java @@ -0,0 +1,32 @@ +/* + * 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.expath.exist.file; + +import org.exist.test.runner.XSuite; +import org.junit.runner.RunWith; + +@RunWith(XSuite.class) +@XSuite.XSuiteFiles({ + "src/test/xquery/org/expath/exist/file" +}) +public class ExpathFileTests { +} diff --git a/extensions/expath/src/test/resources-filtered/conf.xml b/extensions/expath/src/test/resources-filtered/conf.xml index a0e02a2c06d..97aeb503126 100644 --- a/extensions/expath/src/test/resources-filtered/conf.xml +++ b/extensions/expath/src/test/resources-filtered/conf.xml @@ -743,8 +743,10 @@ - + + + diff --git a/extensions/expath/src/test/resources/util/fixtures.xqm b/extensions/expath/src/test/resources/util/fixtures.xqm new file mode 100644 index 00000000000..22b42d54215 --- /dev/null +++ b/extensions/expath/src/test/resources/util/fixtures.xqm @@ -0,0 +1,127 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace fixtures="http://exist-db.org/xquery/test/util/fixtures"; + +(: simple xml :) + +declare variable $fixtures:XML := document {}; + +declare variable $fixtures:SIMPLE_XML_INDENTED := +"" || $fixtures:NL || +" " || $fixtures:NL || +"" +; + +declare variable $fixtures:SIMPLE_XML_UNINDENTED := ""; + +(: more complex xml :) + +declare variable $fixtures:COMPLEX_XML := + + This is a very long line. Certainly longer than eighty characters. It is here to see what happens to lines longer than a certain limit. Let's see. + + + + + MIXED CONTENT + + The next word is highlighted. + +

+ +; + +(: FIXME(JL) cannot use StringConstructor here because that will cause all comparisons to fail on Windows :) +(: see https://github.com/eXist-db/exist/issues/4301 :) +declare variable $fixtures:COMPLEX_XML_INDENTED := +"" || $fixtures:NL || +" This is a very long line. Certainly longer than eighty characters. It is here to see what happens to lines longer than a certain limit. Let's see." || $fixtures:NL || +" " || $fixtures:NL || +" " || $fixtures:NL || +" " || $fixtures:NL || +$fixtures:NL || +" MIXED CONTENT" || $fixtures:NL || +$fixtures:NL || +" " || $fixtures:NL || +" The next word is highlighted." || $fixtures:NL || +" " || $fixtures:NL || +"

" || $fixtures:NL || +"" +; + +declare variable $fixtures:COMPLEX_XML_UNINDENTED := +"" || +"This is a very long line. Certainly longer than eighty characters. It is here to see what happens to lines longer than a certain limit. Let's see." || +"" || +"" || +"" || $fixtures:NL || +$fixtures:NL || +" MIXED CONTENT" || $fixtures:NL || +$fixtures:NL || +" " || $fixtures:NL || +" The next word is highlighted." || $fixtures:NL || +" " || +"

" || +"" +; + + +declare variable $fixtures:TXT := +``[12 12 +This is just a Text +]`` +; + +declare variable $fixtures:XQY := "xquery version ""3.1""; 0 to 9"; +declare variable $fixtures:BIN := "To bin or not to bin..."; + +(: other constants :) + +declare variable $fixtures:XML_DECLARATION := ''; +declare variable $fixtures:NL := " "; + +(: modification dates :) + +declare variable $fixtures:now := current-dateTime(); +declare variable $fixtures:mod-date := $fixtures:now; +declare variable $fixtures:mod-date-2 := $fixtures:now + xs:dayTimeDuration('PT2H'); + +(: collections :) + +declare variable $fixtures:collection-name := "file-module-test"; +declare variable $fixtures:child-collection-name := "data"; +declare variable $fixtures:collection := "/db/" || $fixtures:collection-name; +declare variable $fixtures:child-collection := $fixtures:collection || "/" || $fixtures:child-collection-name; + +(: file sync results :) + +declare variable $fixtures:ALL-UPDATED := ("test-text.txt", "test-query.xq", "bin", "test-data.xml"); + +declare variable $fixtures:ROOT-FS := ("bin", "test-text.txt", "test-query.xq", "data"); + +declare variable $fixtures:EXTRA-DATA := ("test", ".env"); + +declare variable $fixtures:ROOT-FS-EXTRA := ("test", "bin", ".env", "test-text.txt", "test-query.xq", "data"); diff --git a/extensions/expath/src/test/resources/util/helper.xqm b/extensions/expath/src/test/resources/util/helper.xqm new file mode 100644 index 00000000000..bbb4b494e99 --- /dev/null +++ b/extensions/expath/src/test/resources/util/helper.xqm @@ -0,0 +1,231 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace helper="http://exist-db.org/xquery/test/util/helper"; +import module namespace fixtures="http://exist-db.org/xquery/test/util/fixtures" at "fixtures.xqm"; +import module namespace exfile="http://expath.org/ns/file"; +import module namespace xmldb="http://exist-db.org/xquery/xmldb"; +import module namespace util="http://exist-db.org/xquery/util"; + +declare namespace utilns="http://exist-db.org/xquery/util"; + +declare variable $helper:error := xs:QName("helper:assert-sync-error"); + +declare variable $helper:path-separator := util:system-property("file.separator"); + +(: +/db + /file-module-test + /data + test-data.xml + test-text.txt + test-query.xq + bin +:) +declare function helper:setup-db() as empty-sequence() { + let $_ := ( + xmldb:create-collection("/db", $fixtures:collection-name), + helper:create-db-resource($fixtures:collection, "test-text.txt", $fixtures:TXT), + helper:create-db-resource($fixtures:collection, "test-query.xq", $fixtures:XQY), + helper:create-db-resource($fixtures:collection, "bin", $fixtures:BIN), + + xmldb:create-collection($fixtures:collection, $fixtures:child-collection-name), + helper:create-db-resource($fixtures:child-collection, "test-data.xml", $fixtures:XML) + ) + return () +}; + +declare function helper:clear-db() { + xmldb:remove($fixtures:collection) +}; + +declare function helper:create-db-resource($collection as xs:string, $resource as xs:string, $content as item()) as empty-sequence() { + let $_ := xmldb:store($collection, $resource, $content) + return () +}; + +declare function helper:modify-db-resource($collection as xs:string, $resource as xs:string) as empty-sequence() { + let $_ := xmldb:touch($collection, $resource, $fixtures:mod-date-2) + return () +}; + +declare function helper:clear-suite-fs ($suite as xs:string) as empty-sequence() { + let $dir := + helper:glue-path(( + util:system-property("java.io.tmpdir"), + $suite + )) + return + if (exfile:exists($dir)) then + let $_ := exfile:delete($dir, true()) + return () + else () +}; + +declare function helper:clear-fs ($directory as xs:string) as empty-sequence() { + if (exfile:exists($directory)) then + let $_ := exfile:delete($directory, true()) + return () + else () +}; + +declare function helper:get-test-directory ($suite as xs:string) as xs:string { + helper:glue-path(( + util:system-property("java.io.tmpdir"), + $suite, + util:uuid() + )) +}; + +declare function helper:glue-path ($parts as xs:string+) as xs:string { + string-join($parts, $helper:path-separator) +}; + +(: + : clear FS state and simulate additional data on the file system in a specific directory + : @returns given directory to allow use in pipeline (chain of arrow operators) + :) +declare function helper:setup-fs-extra ($directory as xs:string) as xs:string { + let $_ := exfile:create-dir($directory) + let $_ := exfile:create-dir(helper:glue-path(($directory, "test"))) + let $_ := ( + exfile:write-binary( + helper:glue-path(($directory, ".env")), + util:string-to-binary("SERVER_SECRET=123!")), + exfile:write-binary( + helper:glue-path(($directory, "test", "three.s")), + util:string-to-binary("...")) + ) + return $directory +}; + +declare function helper:get-deleted-from-sync-result ($result as element(utilns:sync)) as xs:string* { + $result//utilns:delete/@name/string() +}; + +declare function helper:get-dir-from-sync-result ($result as element(utilns:sync)) as xs:string* { + $result/@utilns:dir/string() +}; + +declare function helper:get-updated-from-sync-result ($result as element(utilns:sync)) as xs:string* { + $result//utilns:update/@name/string() +}; + +declare function helper:list-files-and-directories ($directory as xs:string) as xs:string* { + (: EXPath exfile:list returns relative path strings, directories end with separator :) + for $entry in exfile:list($directory) + return + (: strip trailing separator from directory names :) + if (ends-with($entry, exfile:dir-separator())) + then substring($entry, 1, string-length($entry) - string-length(exfile:dir-separator())) + else $entry +}; + +declare function helper:sync-with-options ($directory as xs:string, $options as item()?) as element(utilns:sync) { + util:file-sync($fixtures:collection, $directory, $options)/* +}; + +declare function helper:assert-sync-result ( + $result as document-node(element(utilns:sync)), + $expected as map(xs:string, xs:string*) +) as xs:boolean { + helper:assert-permutation-of( + $expected?updated, + helper:get-updated-from-sync-result($result/*), + "updated" + ) + and + helper:assert-permutation-of( + $expected?deleted, + helper:get-deleted-from-sync-result($result/*), + "deleted" + ) + and + helper:assert-permutation-of( + $expected?fs, + helper:get-dir-from-sync-result($result/*) + => helper:list-files-and-directories(), + "filesystem" + ) +}; + +declare function helper:assert-permutation-of( + $expected as xs:anyAtomicType*, + $actual as xs:anyAtomicType*, + $label as xs:string +) as xs:boolean { + let $test := fold-left( + $expected, + [true(), $actual], + helper:permutation-reducer#2 + ) + + return + if (empty($expected) and not(empty($actual))) + then error($helper:error, + "Assertion failed (" || $label || "): expected empty sequence" || + " but got (" || string-join($actual, ", ") || ")") + else if (not($test?1 or exists($test?2))) + then error($helper:error, + "Assertion failed (" || $label || "): expected permutation of " || + "(" || string-join($expected, ", ") || ")" || + " but got (" || string-join($actual, ", ") || ")") + else true() +}; + +declare function helper:permutation-reducer ($result, $next) as array(*) { + let $first-index := index-of($result?2, $next)[1] + return [ + $result?1 and $first-index > 0, + helper:maybe-remove-item-at-index($result?2, $first-index) + ] +}; + +declare function helper:maybe-remove-item-at-index($sequence as xs:anyAtomicType*, $index as xs:integer?) as xs:anyAtomicType* { + if ($index = 1) + then subsequence($sequence, 2) + else if ($index > 1) + then ( + subsequence($sequence, 1, $index - 1), + subsequence($sequence, $index + 1) + ) + else $sequence (: do nothing - will be handled later :) +}; + +declare function helper:assert-file-contents($expected as xs:string, $path-parts as xs:string+) as xs:boolean { + let $path := helper:glue-path($path-parts) + let $actual := exfile:read-text($path) + + return + if ( + exists($actual) and + count($actual) = 1 and + $actual eq $expected) + then true() + else error( + $helper:error, + "File Content Assertion failed: expected file " || $path || " " || + "to contain string " || "<[" || $expected || "]>" || + " but was <[" || $actual || "]>" + ) +}; diff --git a/extensions/expath/src/test/xquery/org/expath/exist/file/file-tests.xql b/extensions/expath/src/test/xquery/org/expath/exist/file/file-tests.xql new file mode 100644 index 00000000000..7079255ba83 --- /dev/null +++ b/extensions/expath/src/test/xquery/org/expath/exist/file/file-tests.xql @@ -0,0 +1,547 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace ft="http://exist-db.org/testsuite/expath-file"; + +import module namespace test="http://exist-db.org/xquery/xqsuite" + at "resource:org/exist/xquery/lib/xqsuite/xqsuite.xql"; +import module namespace exfile="http://expath.org/ns/file"; + +(: ======== Helper variables ======== :) + +declare variable $ft:temp-dir := exfile:temp-dir(); +declare variable $ft:test-dir := $ft:temp-dir || "expath-file-test/"; + +(: ======== Setup / Teardown ======== :) + +declare + %test:setUp +function ft:setup() { + exfile:create-dir($ft:test-dir) +}; + +declare + %test:tearDown +function ft:teardown() { + if (exfile:exists($ft:test-dir)) then + exfile:delete($ft:test-dir, true()) + else + () +}; + +(: ======== System Properties ======== :) + +declare + %test:assertExists +function ft:dir-separator() { + exfile:dir-separator() +}; + +declare + %test:assertExists +function ft:line-separator() { + exfile:line-separator() +}; + +declare + %test:assertExists +function ft:path-separator() { + exfile:path-separator() +}; + +declare + %test:assertExists +function ft:temp-dir() { + exfile:temp-dir() +}; + +declare + %test:assertExists +function ft:current-dir() { + exfile:current-dir() +}; + +(: temp-dir and current-dir should end with separator :) +declare + %test:assertTrue +function ft:temp-dir-ends-with-separator() { + ends-with(exfile:temp-dir(), exfile:dir-separator()) +}; + +declare + %test:assertTrue +function ft:current-dir-ends-with-separator() { + ends-with(exfile:current-dir(), exfile:dir-separator()) +}; + +(: ======== File Properties ======== :) + +declare + %test:assertTrue +function ft:exists-temp-dir() { + exfile:exists(exfile:temp-dir()) +}; + +declare + %test:assertFalse +function ft:exists-nonexistent() { + exfile:exists($ft:test-dir || "nonexistent-file.txt") +}; + +declare + %test:assertTrue +function ft:is-dir-temp() { + exfile:is-dir(exfile:temp-dir()) +}; + +declare + %test:assertFalse +function ft:is-dir-nonexistent() { + exfile:is-dir($ft:test-dir || "nonexistent") +}; + +declare + %test:assertTrue +function ft:is-file-after-write() { + let $path := $ft:test-dir || "is-file-test.txt" + let $_ := exfile:write-text($path, "hello") + return exfile:is-file($path) +}; + +declare + %test:assertFalse +function ft:is-file-on-dir() { + exfile:is-file($ft:test-dir) +}; + +declare + %test:assertTrue +function ft:is-absolute-absolute-path() { + exfile:is-absolute(exfile:temp-dir()) +}; + +declare + %test:assertFalse +function ft:is-absolute-relative-path() { + exfile:is-absolute("relative/path") +}; + +declare + %test:assertExists +function ft:last-modified() { + let $path := $ft:test-dir || "last-mod-test.txt" + let $_ := exfile:write-text($path, "test") + return exfile:last-modified($path) +}; + +declare + %test:assertEquals(5) +function ft:size-file() { + let $path := $ft:test-dir || "size-test.txt" + let $_ := exfile:write-text($path, "hello") + return exfile:size($path) +}; + +declare + %test:assertEquals(0) +function ft:size-dir() { + exfile:size($ft:test-dir) +}; + +(: ======== Read / Write Text ======== :) + +declare + %test:assertEquals("hello world") +function ft:write-read-text() { + let $path := $ft:test-dir || "write-read.txt" + let $_ := exfile:write-text($path, "hello world") + return exfile:read-text($path) +}; + +declare + %test:assertEquals("héllo wörld") +function ft:write-read-text-utf8() { + let $path := $ft:test-dir || "write-read-utf8.txt" + let $_ := exfile:write-text($path, "héllo wörld") + return exfile:read-text($path) +}; + +declare + %test:assertEquals("line1", "line2", "line3") +function ft:write-read-text-lines() { + let $path := $ft:test-dir || "lines-test.txt" + let $_ := exfile:write-text-lines($path, ("line1", "line2", "line3")) + return exfile:read-text-lines($path) +}; + +declare + %test:assertEquals(0) +function ft:write-text-lines-empty() { + let $path := $ft:test-dir || "empty-lines.txt" + let $_ := exfile:write-text-lines($path, ()) + return count(exfile:read-text-lines($path)) +}; + +(: Verify newline normalization: CR and CRLF -> LF :) +declare + %test:assertEquals("a", "b", "c") +function ft:read-text-lines-normalization() { + let $path := $ft:test-dir || "crlf-test.txt" + (: Write raw bytes with CRLF line endings :) + let $_ := exfile:write-text($path, "a b c") + return exfile:read-text-lines($path) +}; + +(: ======== Read / Write Binary ======== :) + +declare + %test:assertExists +function ft:write-read-binary() { + let $path := $ft:test-dir || "binary-test.bin" + let $data := xs:base64Binary("SGVsbG8gV29ybGQ=") (: "Hello World" :) + let $_ := exfile:write-binary($path, $data) + return exfile:read-binary($path) +}; + +(: ======== Append ======== :) + +declare + %test:assertEquals("helloworld") +function ft:append-text() { + let $path := $ft:test-dir || "append-test.txt" + let $_ := exfile:write-text($path, "hello") + let $_ := exfile:append-text($path, "world") + return exfile:read-text($path) +}; + +declare + %test:assertEquals("line1", "line2", "line3", "line4") +function ft:append-text-lines() { + let $path := $ft:test-dir || "append-lines.txt" + let $_ := exfile:write-text-lines($path, ("line1", "line2")) + let $_ := exfile:append-text-lines($path, ("line3", "line4")) + return exfile:read-text-lines($path) +}; + +(: ======== Directory Operations ======== :) + +declare + %test:assertTrue +function ft:create-dir() { + let $dir := $ft:test-dir || "subdir/" + let $_ := exfile:create-dir($dir) + return exfile:is-dir($dir) +}; + +declare + %test:assertTrue +function ft:create-dir-nested() { + let $dir := $ft:test-dir || "a/b/c/" + let $_ := exfile:create-dir($dir) + return exfile:is-dir($dir) +}; + +declare + %test:assertExists +function ft:create-temp-dir() { + let $dir := exfile:create-temp-dir("test-", "-dir", $ft:test-dir) + return + if (exfile:is-dir($dir)) then + $dir + else + () +}; + +declare + %test:assertExists +function ft:create-temp-file() { + let $f := exfile:create-temp-file("test-", ".tmp", $ft:test-dir) + return + if (exfile:is-file($f)) then + $f + else + () +}; + +(: ======== List / Children / Descendants ======== :) + +declare + %test:assertExists +function ft:list-dir() { + let $_ := exfile:write-text($ft:test-dir || "list-a.txt", "a") + let $_ := exfile:write-text($ft:test-dir || "list-b.txt", "b") + return exfile:list($ft:test-dir) +}; + +declare + %test:assertTrue +function ft:list-contains-file() { + let $_ := exfile:write-text($ft:test-dir || "list-find.txt", "find me") + return "list-find.txt" = exfile:list($ft:test-dir) +}; + +declare + %test:assertTrue +function ft:list-dir-trailing-separator() { + let $_ := exfile:create-dir($ft:test-dir || "list-subdir") + let $entries := exfile:list($ft:test-dir) + return some $e in $entries satisfies + starts-with($e, "list-subdir") and ends-with($e, exfile:dir-separator()) +}; + +declare + %test:assertTrue +function ft:list-recursive() { + let $_ := exfile:create-dir($ft:test-dir || "rec-dir") + let $_ := exfile:write-text($ft:test-dir || "rec-dir/nested.txt", "nested") + let $entries := exfile:list($ft:test-dir, true()) + return some $e in $entries satisfies contains($e, "nested.txt") +}; + +declare + %test:assertTrue +function ft:list-pattern() { + let $_ := exfile:write-text($ft:test-dir || "pat-a.txt", "a") + let $_ := exfile:write-text($ft:test-dir || "pat-b.xml", "b") + let $entries := exfile:list($ft:test-dir, false(), "*.txt") + return + (some $e in $entries satisfies $e = "pat-a.txt") + and not(some $e in $entries satisfies $e = "pat-b.xml") +}; + +declare + %test:assertExists +function ft:children() { + let $_ := exfile:write-text($ft:test-dir || "child.txt", "x") + return exfile:children($ft:test-dir) +}; + +declare + %test:assertTrue +function ft:children-absolute-paths() { + let $_ := exfile:write-text($ft:test-dir || "child-abs.txt", "x") + let $children := exfile:children($ft:test-dir) + return every $c in $children satisfies exfile:is-absolute($c) +}; + +declare + %test:assertExists +function ft:descendants() { + let $_ := exfile:create-dir($ft:test-dir || "desc-dir") + let $_ := exfile:write-text($ft:test-dir || "desc-dir/deep.txt", "deep") + return exfile:descendants($ft:test-dir) +}; + +declare + %test:assertExists +function ft:list-roots() { + exfile:list-roots() +}; + +(: ======== Copy / Move / Delete ======== :) + +declare + %test:assertEquals("copy content") +function ft:copy-file() { + let $src := $ft:test-dir || "copy-src.txt" + let $dst := $ft:test-dir || "copy-dst.txt" + let $_ := exfile:write-text($src, "copy content") + let $_ := exfile:copy($src, $dst) + return exfile:read-text($dst) +}; + +declare + %test:assertTrue +function ft:move-file() { + let $src := $ft:test-dir || "move-src.txt" + let $dst := $ft:test-dir || "move-dst.txt" + let $_ := exfile:write-text($src, "move content") + let $_ := exfile:move($src, $dst) + return + exfile:exists($dst) and not(exfile:exists($src)) +}; + +declare + %test:assertFalse +function ft:delete-file() { + let $path := $ft:test-dir || "delete-me.txt" + let $_ := exfile:write-text($path, "bye") + let $_ := exfile:delete($path) + return exfile:exists($path) +}; + +declare + %test:assertFalse +function ft:delete-dir-recursive() { + let $dir := $ft:test-dir || "delete-dir/" + let $_ := exfile:create-dir($dir) + let $_ := exfile:write-text($dir || "inner.txt", "inner") + let $_ := exfile:delete($dir, true()) + return exfile:exists($dir) +}; + +(: ======== Path Functions ======== :) + +declare + %test:assertEquals("file.txt") +function ft:name() { + exfile:name("/some/path/file.txt") +}; + +declare + %test:assertEquals("") +function ft:name-root() { + exfile:name("/") +}; + +declare + %test:assertExists +function ft:parent() { + exfile:parent("/some/path/file.txt") +}; + +declare + %test:assertEmpty +function ft:parent-root() { + exfile:parent("/") +}; + +declare + %test:assertTrue +function ft:parent-ends-with-separator() { + ends-with(exfile:parent("/some/path/file.txt"), exfile:dir-separator()) +}; + +declare + %test:assertExists +function ft:path-to-native() { + (: temp-dir definitely exists :) + exfile:path-to-native(exfile:temp-dir()) +}; + +declare + %test:assertTrue +function ft:path-to-uri-starts-with-file() { + starts-with(string(exfile:path-to-uri("/tmp")), "file:/") +}; + +declare + %test:assertTrue +function ft:resolve-path-absolute() { + let $resolved := exfile:resolve-path("relative") + return exfile:is-absolute($resolved) +}; + +declare + %test:assertTrue +function ft:resolve-path-with-base() { + let $resolved := exfile:resolve-path("child.txt", "/some/base/") + return contains($resolved, "base") and contains($resolved, "child.txt") +}; + +(: ======== Serialized Write / Append ======== :) + +declare + %test:assertTrue +function ft:write-xml() { + let $path := $ft:test-dir || "write-xml.xml" + let $_ := exfile:write($path, text) + let $content := exfile:read-text($path) + return contains($content, "") and contains($content, "text") +}; + +declare + %test:assertTrue +function ft:append-xml() { + let $path := $ft:test-dir || "append-xml.xml" + let $_ := exfile:write($path, ) + let $_ := exfile:append($path, ) + let $content := exfile:read-text($path) + return contains($content, "first") and contains($content, "second") +}; + +(: ======== Error Conditions ======== :) + +declare + %test:assertError("exfile:not-found") +function ft:read-text-not-found() { + exfile:read-text($ft:test-dir || "does-not-exist.txt") +}; + +declare + %test:assertError("exfile:is-dir") +function ft:read-text-is-dir() { + exfile:read-text($ft:test-dir) +}; + +declare + %test:assertError("exfile:not-found") +function ft:last-modified-not-found() { + exfile:last-modified($ft:test-dir || "does-not-exist.txt") +}; + +declare + %test:assertError("exfile:not-found") +function ft:size-not-found() { + exfile:size($ft:test-dir || "does-not-exist.txt") +}; + +declare + %test:assertError("exfile:not-found") +function ft:delete-not-found() { + exfile:delete($ft:test-dir || "does-not-exist.txt") +}; + +declare + %test:assertError("exfile:not-found") +function ft:children-not-found() { + exfile:children($ft:test-dir || "does-not-exist/") +}; + +declare + %test:assertError("exfile:no-dir") +function ft:children-not-dir() { + let $path := $ft:test-dir || "not-a-dir.txt" + let $_ := exfile:write-text($path, "x") + return exfile:children($path) +}; + +declare + %test:assertError("exfile:no-dir") +function ft:write-text-no-parent-dir() { + exfile:write-text($ft:test-dir || "no-such-parent/file.txt", "hello") +}; + +declare + %test:assertError("exfile:unknown-encoding") +function ft:read-text-bad-encoding() { + let $path := $ft:test-dir || "encoding-test.txt" + let $_ := exfile:write-text($path, "hello") + return exfile:read-text($path, "not-a-real-encoding") +}; + +declare + %test:assertError("exfile:not-found") +function ft:path-to-native-not-found() { + exfile:path-to-native("/this/path/surely/does/not/exist/anywhere") +}; diff --git a/extensions/expath/src/test/xquery/org/expath/exist/file/sync-serialize.xqm b/extensions/expath/src/test/xquery/org/expath/exist/file/sync-serialize.xqm new file mode 100644 index 00000000000..6c6cbfd2d12 --- /dev/null +++ b/extensions/expath/src/test/xquery/org/expath/exist/file/sync-serialize.xqm @@ -0,0 +1,249 @@ +(: + : 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 + :) +xquery version "3.1"; + +(: + : test serialization defaults and setting different serialization options on + : util:file-sync#3 + :) +module namespace syse="http://exist-db.org/xquery/test/util/sync-serialize"; + +import module namespace util="http://exist-db.org/xquery/util"; +import module namespace helper="http://exist-db.org/xquery/test/util/helper" at "resource:util/helper.xqm"; +import module namespace fixtures="http://exist-db.org/xquery/test/util/fixtures" at "resource:util/fixtures.xqm"; + + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $syse:suite := "syse"; + +declare variable $syse:simple-file-name := "simple-data.xml"; +declare variable $syse:complex-file-name := "complex-data.xml"; + +declare + %test:setUp +function syse:setup() as empty-sequence() { + let $_ := ( + xmldb:create-collection("/db", $fixtures:collection-name), + helper:create-db-resource($fixtures:collection, $syse:simple-file-name, $fixtures:XML), + helper:create-db-resource($fixtures:collection, $syse:complex-file-name, $fixtures:COMPLEX_XML) + ) + return () +}; + +declare + %test:tearDown +function syse:tear-down() { + helper:clear-db(), + helper:clear-suite-fs($syse:suite) +}; + +declare + %test:assertEquals("true", "true") +function syse:defaults() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + () + ) + + return ( + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:SIMPLE_XML_INDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:COMPLEX_XML_INDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:indent-no() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{"indent": false()} + ) + + return ( + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:SIMPLE_XML_UNINDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:COMPLEX_XML_UNINDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:indent-yes() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{"indent": true()} + ) + + return ( + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:SIMPLE_XML_INDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:COMPLEX_XML_INDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:omit-xml-declaration-no() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{"omit-xml-declaration": false()} + ) + + return ( + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:SIMPLE_XML_INDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:COMPLEX_XML_INDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:omit-xml-declaration-yes() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{"omit-xml-declaration": true()} + ) + + return ( + helper:assert-file-contents( + $fixtures:SIMPLE_XML_INDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:COMPLEX_XML_INDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:unindented-no-declaration() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{ + "omit-xml-declaration": true(), + "indent": false() + } + ) + + return ( + helper:assert-file-contents( + $fixtures:SIMPLE_XML_UNINDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:COMPLEX_XML_UNINDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:insert-final-newline-yes() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{ "exist:insert-final-newline": true() } + ) + + return ( + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:SIMPLE_XML_INDENTED || $fixtures:NL, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:COMPLEX_XML_INDENTED || $fixtures:NL, + ($directory, $syse:complex-file-name) + ) + ) +}; + +declare + %test:assertEquals("true", "true") +function syse:insert-final-newline-no() { + let $directory := helper:get-test-directory($syse:suite) + let $sync := util:file-sync( + $fixtures:collection, + $directory, + map{ "exist:insert-final-newline": false() } + ) + + return ( + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:SIMPLE_XML_INDENTED, + ($directory, $syse:simple-file-name) + ), + helper:assert-file-contents( + $fixtures:XML_DECLARATION || $fixtures:NL || + $fixtures:COMPLEX_XML_INDENTED, + ($directory, $syse:complex-file-name) + ) + ) +}; diff --git a/extensions/expath/src/test/xquery/org/expath/exist/file/sync.xqm b/extensions/expath/src/test/xquery/org/expath/exist/file/sync.xqm new file mode 100644 index 00000000000..d6f9d860ace --- /dev/null +++ b/extensions/expath/src/test/xquery/org/expath/exist/file/sync.xqm @@ -0,0 +1,424 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace sync="http://exist-db.org/xquery/test/util/sync"; + +import module namespace util="http://exist-db.org/xquery/util"; +import module namespace exfile="http://expath.org/ns/file"; +import module namespace helper="http://exist-db.org/xquery/test/util/helper" at "resource:util/helper.xqm"; +import module namespace fixtures="http://exist-db.org/xquery/test/util/fixtures" at "resource:util/fixtures.xqm"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $sync:suite := "sync"; + +declare + %test:setUp +function sync:setup() as empty-sequence() { + helper:setup-db() +}; + +declare + %test:tearDown +function sync:tear-down() { + helper:clear-db(), + helper:clear-suite-fs($sync:suite) +}; + +declare + %test:assertTrue +function sync:simple() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + () + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function sync:empty-options-map() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + map{} + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertError +function sync:deprecated-options() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + $fixtures:mod-date + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-1() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + xs:date("2012-12-21") + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-2() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + "2012-12-21T10:12:21" + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-3() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + "lizard" + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-4() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + "" + ) +}; + +(: + : TODO(JL) should also report %test:assertError("err:XPTY0004") + : it is wrapped in org.exist.xquery.XPathException and therefore not recognized + :) +declare + %test:assertError +function sync:bad-options-5() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + (1, map{}, "") + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-6() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + map{ "prune": "true" } + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-7() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + map{ "prune": "no" } + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-8() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + map{ "after": 1234325 } + ) +}; + +declare + %test:assertError("err:XPTY0004") +function sync:bad-options-9() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($sync:suite), + map{ "excludes": [] } + ) +}; + +declare + %test:assertTrue +function sync:do-not-prune() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": false() } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS-EXTRA + }) +}; + +declare + %test:assertTrue +function sync:prune() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true() } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": ("test", "three.s", ".env"), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function sync:prune-with-excludes-matching-none() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true(), "excludes": "*.txt" } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": ("test", "three.s", ".env"), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function sync:after() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "after": $fixtures:mod-date } + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": (), + "fs": ($fixtures:EXTRA-DATA, "data") (: TODO: data should not be here! :) + }) +}; + +declare + %test:assertTrue +function sync:after-mod-date-2() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "after": $fixtures:mod-date-2 } + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": (), + "fs": ($fixtures:EXTRA-DATA, "data") (: TODO: data should not be here! :) + }) +}; + +declare + %test:assertTrue +function sync:after-with-excludes() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "after": $fixtures:mod-date, "excludes": ".env" } + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": (), + "fs": ($fixtures:EXTRA-DATA, "data") (: TODO: data should not be here! :) + }) +}; + +declare + %test:assertTrue +function sync:prune-with-after-and-excludes() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + let $_ := ( + exfile:write-binary( + $directory || "/excluded.xq", + util:string-to-binary("1") + ), + exfile:write-binary( + $directory || "/pruned.xql", + util:string-to-binary("1") + ), + exfile:write-binary( + $directory || "/readme.md", + util:string-to-binary("oh oh") + ) + ) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ + "after": $fixtures:mod-date, + "excludes": "*.xq", + "prune": true() + } + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": ("three.s", "test", ".env", "pruned.xql", "readme.md"), + "fs": ("excluded.xq", "data") (: TODO: data should not be here! :) + }) +}; + +declare + %test:assertTrue +function sync:prunes-a-directory() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true(), "excludes": ".*" } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": ("test", "three.s"), + "fs": ($fixtures:ROOT-FS, ".env") + }) +}; + +declare + %test:assertTrue +function sync:prunes-a-file() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true(), "excludes": "test" || $helper:path-separator || "*" } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (".env"), + "fs": ($fixtures:ROOT-FS, "test") + }) +}; + +declare + %test:assertTrue +function sync:prunes-with-multiple-excludes() { + let $directory := helper:get-test-directory($sync:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ + "prune": true(), + "excludes": (".env", "**" || $helper:path-separator || "three.?") + } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": ($fixtures:ROOT-FS, ".env", "test") + }) +}; + +declare + %test:assertTrue +function sync:twice() { + let $directory := helper:get-test-directory($sync:suite) + (: + : ensure that files on disk are always recognized as newer by waiting one second until + : syncing to disk, see https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8177809 + :) + let $_ := util:wait(1000) + let $_ := util:file-sync( + $fixtures:collection, + $directory, + () + ) + + return + util:file-sync( + $fixtures:collection, + $directory, + () + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; diff --git a/extensions/expath/src/test/xquery/org/expath/exist/file/syncmod.xqm b/extensions/expath/src/test/xquery/org/expath/exist/file/syncmod.xqm new file mode 100644 index 00000000000..f37282be8da --- /dev/null +++ b/extensions/expath/src/test/xquery/org/expath/exist/file/syncmod.xqm @@ -0,0 +1,294 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace syncmod="http://exist-db.org/xquery/test/util/syncmod"; + +import module namespace util="http://exist-db.org/xquery/util"; +import module namespace exfile="http://expath.org/ns/file"; +import module namespace helper="http://exist-db.org/xquery/test/util/helper" at "resource:util/helper.xqm"; +import module namespace fixtures="http://exist-db.org/xquery/test/util/fixtures" at "resource:util/fixtures.xqm"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $syncmod:suite := "sync-modified"; + +(: + : Same setup as for basic sync tests in sync.xqm + : In addition this time two files are modified an hour after ($fixtures:mod-date) + :) +declare + %test:setUp +function syncmod:setup() as empty-sequence() { + helper:setup-db(), + helper:modify-db-resource($fixtures:child-collection, "test-data.xml"), + helper:modify-db-resource($fixtures:collection, "test-text.txt") +}; + +declare + %test:tearDown +function syncmod:tearDown() { + helper:clear-db(), + helper:clear-suite-fs($syncmod:suite) +}; + +declare + %test:assertTrue +function syncmod:simple() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($syncmod:suite), + () + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function syncmod:empty-options() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($syncmod:suite), + map{} + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertError +function syncmod:deprecated-options() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($syncmod:suite), + $fixtures:mod-date + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function syncmod:do-not-prune() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": false() } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": (), + "fs": $fixtures:ROOT-FS-EXTRA + }) +}; + +declare + %test:assertTrue +function syncmod:prune() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true() } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": ("test", "three.s", ".env"), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function syncmod:prune-with-excludes-matching-none() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true(), "excludes": "*.txt" } + ) + => helper:assert-sync-result(map { + "updated": $fixtures:ALL-UPDATED, + "deleted": ("test", "three.s", ".env"), + "fs": $fixtures:ROOT-FS + }) +}; + +declare + %test:assertTrue +function syncmod:after() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "after": $fixtures:mod-date } + ) + => helper:assert-sync-result(map { + "updated": ("test-text.txt", "test-data.xml"), + "deleted": (), + "fs": ("test", ".env", "test-text.txt", "data") + }) +}; + +(: collections seem to be synced regardless of their content :) +declare + %test:assertTrue +function syncmod:after-mod-date-2() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($syncmod:suite), + map{ "after": $fixtures:mod-date-2 } + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": (), + "fs": ("data") (: TODO: data should not be here! :) + }) +}; + +declare + %test:pending("this would only work if exclude patterns would exclude DB resources from syncing") + %test:assertTrue +function syncmod:exclude-changed-files() { + util:file-sync( + $fixtures:collection, + helper:get-test-directory($syncmod:suite), + map{ "excludes":("*.txt", "data/*"), "after": $fixtures:mod-date } + ) + => helper:assert-sync-result(map { + "updated": (), + "deleted": (), + "fs": () + }) +}; + +declare + %test:assertTrue +function syncmod:prune-with-after-and-excludes-matching-none() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ + "after": $fixtures:mod-date, + "excludes": "QQQ", + "prune": true() + } + ) + => helper:assert-sync-result(map { + "updated": ("test-text.txt", "test-data.xml"), + "deleted": (".env", "test"), + "fs": ("test-text.txt", "data") + }) +}; + +declare + %test:assertTrue +function syncmod:prune-with-after-and-excludes-matching-all() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ + "after": $fixtures:mod-date, + "excludes": "**", + "prune": true() + } + ) + => helper:assert-sync-result(map { + "updated": ("test-text.txt"), (: TODO , "test-data.xml" is missing here :) + "deleted": (), + "fs": (".env", "test", "test-text.txt", "data") + }) +}; + +declare + %test:assertTrue +function syncmod:prunes-a-directory-with-after() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ "prune": true(), "excludes": ".*", "after": $fixtures:mod-date } + ) + => helper:assert-sync-result(map { + "updated": ("test-text.txt", "test-data.xml"), + "deleted": ("test", "three.s"), + "fs": (".env", "test-text.txt", "data") + }) +}; + +declare + %test:pending + %test:assertTrue +function syncmod:prunes-a-file-with-after() { + let $directory := helper:get-test-directory($syncmod:suite) + let $_ := helper:setup-fs-extra($directory) + + return + util:file-sync( + $fixtures:collection, + $directory, + map{ + "after": $fixtures:mod-date, + "excludes": "test/*", + "prune": true() + } + ) + => helper:assert-sync-result(map { + "updated": ("test-text.txt", "test-data.xml"), + "deleted": (".env"), + "fs": ("test", "test-text.txt", "data") + }) +}; diff --git a/extensions/modules/pom.xml b/extensions/modules/pom.xml index 0f8bf723555..3eb56caa2e9 100644 --- a/extensions/modules/pom.xml +++ b/extensions/modules/pom.xml @@ -52,9 +52,9 @@ cqlparser example exi + file expathrepo expathrepo/expathrepo-trigger-test - file image jndi mail