This is an automated email from the ASF dual-hosted git repository. sjaranowski pushed a commit to branch maven-clean-plugin-3.x in repository https://gitbox.apache.org/repos/asf/maven-clean-plugin.git
The following commit(s) were added to refs/heads/maven-clean-plugin-3.x by this push: new 7350dbe Configuration parameter for deleting read-only files 7350dbe is described below commit 7350dbebdad88eacc7fd5fb0e1f9cb7b083902a1 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Tue May 27 23:18:16 2025 +0200 Configuration parameter for deleting read-only files https://github.com/apache/maven-clean-plugin/pull/250 --- src/it/read-only/pom.xml | 48 ++++++++++++ src/it/read-only/setup.bsh | 37 ++++++++++ .../target/read-only-dir/read-only.properties | 16 ++++ .../target/writable-dir/writable.properties | 16 ++++ src/it/read-only/verify.bsh | 26 +++++++ .../org/apache/maven/plugins/clean/CleanMojo.java | 10 ++- .../org/apache/maven/plugins/clean/Cleaner.java | 86 +++++++++++++++++++--- .../apache/maven/plugins/clean/CleanMojoTest.java | 2 +- .../apache/maven/plugins/clean/CleanerTest.java | 10 +-- 9 files changed, 232 insertions(+), 19 deletions(-) diff --git a/src/it/read-only/pom.xml b/src/it/read-only/pom.xml new file mode 100644 index 0000000..a3c0e83 --- /dev/null +++ b/src/it/read-only/pom.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +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. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <groupId>test</groupId> + <artifactId>read-only</artifactId> + <version>1.0-SNAPSHOT</version> + + <name>Test for clean</name> + <description>Check for proper deletion (or not) of read-only files.</description> + + <properties> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + </properties> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-clean-plugin</artifactId> + <version>@pom.version@</version> + <configuration> + <force>true</force> + <retryOnError>false</retryOnError> + </configuration> + </plugin> + </plugins> + </build> + +</project> diff --git a/src/it/read-only/setup.bsh b/src/it/read-only/setup.bsh new file mode 100644 index 0000000..799b411 --- /dev/null +++ b/src/it/read-only/setup.bsh @@ -0,0 +1,37 @@ +/* + * 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. + */ + +import java.io.File; + +if (!new File(basedir, "target/read-only-dir/read-only.properties").setWritable(false)) { + System.out.println("Cannot change file permission."); + return false; +} +if (File.separatorChar == '/') { + // Directory permission can be changed only on Unix, not on Windows. + if (!new File(basedir, "target/read-only-dir").setWritable(false)) { + System.out.println("Cannot change directory permission."); + return false; + } +} +if (!new File(basedir, "target/writable-dir/writable.properties").canWrite()) { + System.out.println("Expected a writable file."); + return false; +} +return true; diff --git a/src/it/read-only/target/read-only-dir/read-only.properties b/src/it/read-only/target/read-only-dir/read-only.properties new file mode 100644 index 0000000..13a8339 --- /dev/null +++ b/src/it/read-only/target/read-only-dir/read-only.properties @@ -0,0 +1,16 @@ +# 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. diff --git a/src/it/read-only/target/writable-dir/writable.properties b/src/it/read-only/target/writable-dir/writable.properties new file mode 100644 index 0000000..13a8339 --- /dev/null +++ b/src/it/read-only/target/writable-dir/writable.properties @@ -0,0 +1,16 @@ +# 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. diff --git a/src/it/read-only/verify.bsh b/src/it/read-only/verify.bsh new file mode 100644 index 0000000..ed830cc --- /dev/null +++ b/src/it/read-only/verify.bsh @@ -0,0 +1,26 @@ +/* + * 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. + */ + +import java.io.File; + +if (new File(basedir, "target").exists()) { + System.out.println("target should have been deleted."); + return false; +} +return true; diff --git a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java index 0c3ee3a..3a82d71 100644 --- a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java +++ b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java @@ -128,6 +128,14 @@ public class CleanMojo extends AbstractMojo { @Parameter(property = "maven.clean.followSymLinks", defaultValue = "false") private boolean followSymLinks; + /** + * Whether to force the deletion of read-only files. + * + * @since 3.4.2 + */ + @Parameter(property = "maven.clean.force", defaultValue = "false") + private boolean force; + /** * Disables the plugin execution. <br/> * Starting with <code>3.0.0</code> the property has been renamed from <code>clean.skip</code> to @@ -249,7 +257,7 @@ public class CleanMojo extends AbstractMojo { + FAST_MODE_BACKGROUND + "', '" + FAST_MODE_AT_END + "' and '" + FAST_MODE_DEFER + "'."); } - Cleaner cleaner = new Cleaner(session, getLog(), isVerbose(), fastDir, fastMode); + Cleaner cleaner = new Cleaner(session, getLog(), isVerbose(), fastDir, fastMode, force); try { for (Path directoryItem : getDirectories()) { diff --git a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java index ef05038..8e4b107 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java +++ b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java @@ -23,14 +23,21 @@ import java.io.IOException; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; +import java.nio.file.AccessDeniedException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.DosFileAttributeView; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayDeque; import java.util.Deque; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; import org.apache.maven.execution.ExecutionListener; import org.apache.maven.execution.MavenSession; @@ -68,6 +75,13 @@ class Cleaner { private Log log; + /** + * Whether to force the deletion of read-only files. Note that on Linux, + * {@link Files#delete(Path)} and {@link Files#deleteIfExists(Path)} delete read-only files + * but throw {@link AccessDeniedException} if the directory containing the file is read-only. + */ + private final boolean force; + /** * Creates a new cleaner. * @@ -76,8 +90,9 @@ class Cleaner { * @param verbose Whether to perform verbose logging. * @param fastDir The explicit configured directory or to be deleted in fast mode. * @param fastMode The fast deletion mode. + * @param force whether to force the deletion of read-only files */ - Cleaner(MavenSession session, final Log log, boolean verbose, Path fastDir, String fastMode) { + Cleaner(MavenSession session, final Log log, boolean verbose, Path fastDir, String fastMode, boolean force) { this.session = session; // This can't be null as the Cleaner gets it from the CleanMojo which gets it from AbstractMojo class, where it // is never null. @@ -85,6 +100,7 @@ class Cleaner { this.fastDir = fastDir; this.fastMode = fastMode; this.verbose = verbose; + this.force = force; } /** @@ -262,6 +278,38 @@ class Cleaner { || (attrs.isDirectory() && attrs.isOther()); } + /** + * Makes the given file or directory writable. + * If the file is already writable, then this method tries to make the parent directory writable. + * + * @param file the path to the file or directory to make writable, or {@code null} if none + * @return the root path which has been made writable, or {@code null} if none + */ + private static Path setWritable(Path file) throws IOException { + while (file != null) { + PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class); + if (posix != null) { + EnumSet<PosixFilePermission> permissions = + EnumSet.copyOf(posix.readAttributes().permissions()); + if (permissions.add(PosixFilePermission.OWNER_WRITE)) { + posix.setPermissions(permissions); + return file; + } + } else { + DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class); + if (dos == null) { + return null; // Unknown type of file attributes. + } + // No need to update the parent directory because DOS read-only attribute does not apply to folders. + dos.setReadOnly(false); + return file; + } + // File was already writable. Maybe it is the parent directory which was not writable. + file = file.getParent(); + } + return null; + } + /** * Deletes the specified file, directory. If the path denotes a symlink, only the link is removed, its target is * left untouched. @@ -276,21 +324,35 @@ class Cleaner { IOException failure = delete(file); if (failure != null) { boolean deleted = false; - - if (retryOnError) { - if (ON_WINDOWS) { - // try to release any locks held by non-closed files - System.gc(); + boolean tryWritable = force && failure instanceof AccessDeniedException; + if (tryWritable || retryOnError) { + final Set<Path> madeWritable; // Safety against never-ending loops. + if (force) { + madeWritable = new HashSet<>(); + madeWritable.add(null); // For having `add(null)` to return `false`. + } else { + madeWritable = null; } - final int[] delays = {50, 250, 750}; - for (int i = 0; !deleted && i < delays.length; i++) { - try { - Thread.sleep(delays[i]); - } catch (InterruptedException e) { - // ignore + int delayIndex = 0; + while (!deleted && delayIndex < delays.length) { + if (tryWritable) { + tryWritable = madeWritable.add(setWritable(file)); + // `true` if we successfully changed permission, in which case we will skip the delay. + } + if (!tryWritable) { + if (ON_WINDOWS) { + // Try to release any locks held by non-closed files. + System.gc(); + } + try { + Thread.sleep(delays[delayIndex++]); + } catch (InterruptedException e) { + // ignore + } } deleted = delete(file) == null || !exists(file); + tryWritable = !deleted && force && failure instanceof AccessDeniedException; } } else { deleted = !exists(file); diff --git a/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java b/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java index 06b7297..9fbf98f 100644 --- a/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java @@ -250,7 +250,7 @@ class CleanMojoTest { private void testSymlink(LinkCreator linkCreator) throws Exception { // We use the SystemStreamLog() as the AbstractMojo class, because from there the Log is always provided - Cleaner cleaner = new Cleaner(null, new SystemStreamLog(), false, null, null); + Cleaner cleaner = new Cleaner(null, new SystemStreamLog(), false, null, null, false); Path testDir = Paths.get("target/test-classes/unit/test-dir").toAbsolutePath(); Path dirWithLnk = testDir.resolve("dir"); Path orgDir = testDir.resolve("org-dir"); diff --git a/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java b/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java index d5ccbc0..98aeb8d 100644 --- a/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java +++ b/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java @@ -61,7 +61,7 @@ class CleanerTest { void deleteSucceedsDeeply(@TempDir Path tempDir) throws Exception { final Path basedir = createDirectory(tempDir.resolve("target")).toRealPath(); final Path file = createFile(basedir.resolve("file")); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); + final Cleaner cleaner = new Cleaner(null, log, false, null, null, false); cleaner.delete(basedir, null, false, true, false); assertFalse(exists(basedir)); assertFalse(exists(file)); @@ -76,7 +76,7 @@ class CleanerTest { // Remove the executable flag to prevent directory listing, which will result in a DirectoryNotEmptyException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rw-rw-r--"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); + final Cleaner cleaner = new Cleaner(null, log, false, null, null, false); final IOException exception = assertThrows(IOException.class, () -> cleaner.delete(basedir, null, false, true, false)); verify(log, never()).warn(any(CharSequence.class), any(Throwable.class)); @@ -94,7 +94,7 @@ class CleanerTest { // Remove the executable flag to prevent directory listing, which will result in a DirectoryNotEmptyException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rw-rw-r--"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); + final Cleaner cleaner = new Cleaner(null, log, false, null, null, false); final IOException exception = assertThrows(IOException.class, () -> cleaner.delete(basedir, null, false, true, true)); assertEquals("Failed to delete " + basedir, exception.getMessage()); @@ -112,7 +112,7 @@ class CleanerTest { // Remove the writable flag to prevent deletion of the file, which will result in an AccessDeniedException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("r-xr-xr-x"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); + final Cleaner cleaner = new Cleaner(null, log, false, null, null, false); assertDoesNotThrow(() -> cleaner.delete(basedir, null, false, false, false)); verify(log, times(2)).warn(any(CharSequence.class), any(Throwable.class)); InOrder inOrder = inOrder(log); @@ -133,7 +133,7 @@ class CleanerTest { // Remove the writable flag to prevent deletion of the file, which will result in an AccessDeniedException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("r-xr-xr-x"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); + final Cleaner cleaner = new Cleaner(null, log, false, null, null, false); assertDoesNotThrow(() -> cleaner.delete(basedir, null, false, false, false)); verify(log, never()).warn(any(CharSequence.class), any(Throwable.class)); }