From 69a191c15a1d7cbfdf94d4e12af445458222dadd Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Fri, 6 Dec 2024 17:10:54 +0100 Subject: [PATCH] Better handling of file --- .../maven/toolbox/shared/FileUtils.java | 224 ++++++++++++++++++ .../shared/internal/PomTransformerSink.java | 18 +- .../maven/toolbox/plugin/mp/VersionsMojo.java | 41 ++-- 3 files changed, 247 insertions(+), 36 deletions(-) create mode 100644 shared/src/main/java/eu/maveniverse/maven/toolbox/shared/FileUtils.java diff --git a/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/FileUtils.java b/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/FileUtils.java new file mode 100644 index 0000000..ad23059 --- /dev/null +++ b/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/FileUtils.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package eu.maveniverse.maven.toolbox.shared; + +import static java.util.Objects.requireNonNull; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A utility class to write files. + */ +public final class FileUtils { + // Logic borrowed from Commons-Lang3: we really need only this, to decide do we "atomic move" or not + private static final boolean IS_WINDOWS = + System.getProperty("os.name", "unknown").startsWith("Windows"); + + private FileUtils() {} + + /** + * A temporary file, that is removed when closed. + */ + public interface TempFile extends Closeable { + /** + * Returns the path of the created temp file. + */ + Path getPath(); + } + + /** + * A collocated temporary file, that resides next to a "target" file, and is removed when closed. + */ + public interface CollocatedTempFile extends TempFile { + /** + * Upon close, atomically moves temp file to target file it is collocated with overwriting target (if exists). + * Invocation of this method merely signals that caller ultimately wants temp file to replace the target + * file, but when this method returns, the move operation did not yet happen, it will happen when this + * instance is closed. + *

+ * Invoking this method without writing to temp file {@link #getPath()} (thus, not creating a temp + * file to be moved) is considered a bug, a mistake of the caller. Caller of this method should ensure + * that this method is invoked ONLY when the temp file is created and moving it to its final place is + * required. + */ + void move() throws IOException; + } + + /** + * Creates a {@link TempFile} instance and backing temporary file on file system. It will be located in the default + * temporary-file directory. Returned instance should be handled in try-with-resource construct and created + * temp file is removed (if exists) when returned instance is closed. + *

+ * This method uses {@link Files#createTempFile(String, String, java.nio.file.attribute.FileAttribute[])} to create + * the temporary file on file system. + */ + public static TempFile newTempFile() throws IOException { + Path tempFile = Files.createTempFile("resolver", "tmp"); + return new TempFile() { + @Override + public Path getPath() { + return tempFile; + } + + @Override + public void close() throws IOException { + Files.deleteIfExists(tempFile); + } + }; + } + + /** + * Creates a {@link CollocatedTempFile} instance for given file without backing file. The path will be located in + * same directory where given file is, and will reuse its name for generated (randomized) name. Returned instance + * should be handled in try-with-resource and created temp path is removed (if exists) when returned instance is + * closed. The {@link CollocatedTempFile#move()} makes possible to atomically replace passed in file with the + * processed content written into a file backing the {@link CollocatedTempFile} instance. + *

+ * The {@code file} nor it's parent directories have to exist. The parent directories are created if needed. + *

+ * This method uses {@link Path#resolve(String)} to create the temporary file path in passed in file parent + * directory, but it does NOT create backing file on file system. + */ + public static CollocatedTempFile newTempFile(Path file, boolean copyIfExists) throws IOException { + requireNonNull(file, "file"); + if (Files.isDirectory(file)) { + throw new IllegalArgumentException("file " + file + " is a directory"); + } + Path parent = requireNonNull(file.getParent(), "file must have parent"); + Path tempFile = parent.resolve(file.getFileName() + "." + + Long.toUnsignedString(ThreadLocalRandom.current().nextLong()) + ".tmp"); + Files.createDirectories(parent); + if (Files.exists(file) && copyIfExists) { + if (IS_WINDOWS) { + copy(file, tempFile); + } else { + Files.copy(file, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + } + return new CollocatedTempFile() { + private final AtomicBoolean wantsMove = new AtomicBoolean(false); + + @Override + public Path getPath() { + return tempFile; + } + + @Override + public void move() { + wantsMove.set(true); + } + + @Override + public void close() throws IOException { + if (wantsMove.get()) { + if (IS_WINDOWS) { + copy(tempFile, file); + } else { + Files.move(tempFile, file, StandardCopyOption.REPLACE_EXISTING); + } + } + Files.deleteIfExists(tempFile); + } + }; + } + + /** + * On Windows we use pre-NIO2 way to copy files, as for some reason it works. Beat me why. + */ + private static void copy(Path source, Path target) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(1024 * 32); + byte[] array = buffer.array(); + try (InputStream is = Files.newInputStream(source); + OutputStream os = Files.newOutputStream(target)) { + while (true) { + int bytes = is.read(array); + if (bytes < 0) { + break; + } + os.write(array, 0, bytes); + } + } + } + + /** + * A file writer, that accepts a {@link Path} to write some content to. Note: the file denoted by path may exist, + * hence implementation have to ensure it is able to achieve its goal ("replace existing" option or equivalent + * should be used). + */ + @FunctionalInterface + public interface FileWriter { + void write(Path path) throws IOException; + } + + /** + * Writes file without backup. + * + * @param target that is the target file (must be file, the path must have parent). + * @param writer the writer that will accept a {@link Path} to write content to. + * @throws IOException if at any step IO problem occurs. + */ + public static void writeFile(Path target, FileWriter writer) throws IOException { + writeFile(target, writer, false); + } + + /** + * Writes file with backup copy (appends ".bak" extension). + * + * @param target that is the target file (must be a file, the path must have parent). + * @param writer the writer that will accept a {@link Path} to write content to. + * @throws IOException if at any step IO problem occurs. + */ + public static void writeFileWithBackup(Path target, FileWriter writer) throws IOException { + writeFile(target, writer, true); + } + + /** + * Utility method to write out file to disk in "atomic" manner, with optional backups (".bak") if needed. This + * ensures that no other thread or process will be able to read not fully written files. Finally, this methos + * may create the needed parent directories, if the passed in target parents does not exist. + * + * @param target that is the target file (must be an existing or non-existing file, the path must have parent). + * @param writer the writer that will accept a {@link Path} to write content to. + * @param doBackup if {@code true}, and target file is about to be overwritten, a ".bak" file with old contents will + * be created/overwritten. + * @throws IOException if at any step IO problem occurs. + */ + private static void writeFile(Path target, FileWriter writer, boolean doBackup) throws IOException { + requireNonNull(target, "target is null"); + requireNonNull(writer, "writer is null"); + Path parent = requireNonNull(target.getParent(), "target must have parent"); + + try (CollocatedTempFile tempFile = newTempFile(target, false)) { + writer.write(tempFile.getPath()); + if (doBackup && Files.isRegularFile(target)) { + Files.copy(target, parent.resolve(target.getFileName() + ".bak"), StandardCopyOption.REPLACE_EXISTING); + } + tempFile.move(); + } + } +} diff --git a/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/internal/PomTransformerSink.java b/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/internal/PomTransformerSink.java index c736c75..ffbb5a6 100644 --- a/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/internal/PomTransformerSink.java +++ b/shared/src/main/java/eu/maveniverse/maven/toolbox/shared/internal/PomTransformerSink.java @@ -16,7 +16,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -160,7 +159,6 @@ public static PomTransformerSink transform( private final Output output; private final Path pom; - private final boolean existingPom; private final Predicate artifactMatcher; private final Function artifactMapper; private final Function transformations; @@ -188,16 +186,9 @@ private PomTransformerSink( throws IOException { this.output = requireNonNull(output, "output"); this.pom = requireNonNull(pom, "pom").toAbsolutePath(); - if (Files.isRegularFile(pom)) { - existingPom = true; - Files.copy( - pom, - pom.getParent().resolve(getPomPath().getFileName() + ".bak"), - StandardCopyOption.REPLACE_EXISTING); - } else { + if (!Files.isRegularFile(pom)) { Files.createDirectories(pom.getParent()); Files.writeString(pom, blankPomSupplier.get(), StandardCharsets.UTF_8); - existingPom = false; } this.artifactMatcher = requireNonNull(artifactMatcher, "artifactMatcher"); this.artifactMapper = requireNonNull(artifactMapper, "artifactMapper"); @@ -226,13 +217,6 @@ public void accept(Artifact artifact) throws IOException { public void cleanup(Exception e) { try { Files.deleteIfExists(pom); - if (existingPom) { - // return existing one backup - Files.move( - pom.getParent().resolve(getPomPath().getFileName() + ".bak"), - pom, - StandardCopyOption.REPLACE_EXISTING); - } } catch (IOException ex) { // ignore } diff --git a/toolbox/src/main/java/eu/maveniverse/maven/toolbox/plugin/mp/VersionsMojo.java b/toolbox/src/main/java/eu/maveniverse/maven/toolbox/plugin/mp/VersionsMojo.java index 8d27372..12338f8 100644 --- a/toolbox/src/main/java/eu/maveniverse/maven/toolbox/plugin/mp/VersionsMojo.java +++ b/toolbox/src/main/java/eu/maveniverse/maven/toolbox/plugin/mp/VersionsMojo.java @@ -8,6 +8,7 @@ package eu.maveniverse.maven.toolbox.plugin.mp; import eu.maveniverse.maven.toolbox.plugin.MPMojoSupport; +import eu.maveniverse.maven.toolbox.shared.FileUtils; import eu.maveniverse.maven.toolbox.shared.ResolutionRoot; import eu.maveniverse.maven.toolbox.shared.Result; import eu.maveniverse.maven.toolbox.shared.ToolboxCommando; @@ -59,25 +60,27 @@ protected Result doExecute() throws Exception { toolboxCommando.parseArtifactVersionMatcherSpec(artifactVersionMatcherSpec)); if (applyToPom) { - try (PomTransformerSink sink = PomTransformerSink.transform( - getOutput(), - mavenProject.getFile().toPath(), - PomTransformerSink.updateManagedDependencyVersion())) { - sink.accept(managedDependencies.getData().orElseThrow().entrySet().stream() - .filter(e -> !e.getValue().isEmpty()) - .map(e -> e.getKey() - .setVersion(e.getValue() - .get(e.getValue().size() - 1) - .toString()))); - } - try (PomTransformerSink sink = PomTransformerSink.transform( - getOutput(), mavenProject.getFile().toPath(), PomTransformerSink.updateDependencyVersion())) { - sink.accept(dependencies.getData().orElseThrow().entrySet().stream() - .filter(e -> !e.getValue().isEmpty()) - .map(e -> e.getKey() - .setVersion(e.getValue() - .get(e.getValue().size() - 1) - .toString()))); + try (FileUtils.CollocatedTempFile pom = + FileUtils.newTempFile(mavenProject.getFile().toPath(), true)) { + try (PomTransformerSink sink = PomTransformerSink.transform( + getOutput(), pom.getPath(), PomTransformerSink.updateManagedDependencyVersion())) { + sink.accept(managedDependencies.getData().orElseThrow().entrySet().stream() + .filter(e -> !e.getValue().isEmpty()) + .map(e -> e.getKey() + .setVersion(e.getValue() + .get(e.getValue().size() - 1) + .toString()))); + } + try (PomTransformerSink sink = PomTransformerSink.transform( + getOutput(), pom.getPath(), PomTransformerSink.updateDependencyVersion())) { + sink.accept(dependencies.getData().orElseThrow().entrySet().stream() + .filter(e -> !e.getValue().isEmpty()) + .map(e -> e.getKey() + .setVersion(e.getValue() + .get(e.getValue().size() - 1) + .toString()))); + } + pom.move(); } } return Result.success(true);