diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java index a545dc128f2d..aa5208fba3a4 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java @@ -739,5 +739,11 @@ public final class Constants { */ public static final String MAVEN_MODEL_PROCESSOR_REFERENCE_TYPE_PREFIX = "maven.model.processor.referenceType."; + /** + * System property to keep temp material for diagnostics. + * + */ + public static final String KEEP_PROP = "maven.tempfile.keep"; + private Constants() {} } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/TempFileService.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/TempFileService.java new file mode 100644 index 000000000000..c3ede933c7da --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/TempFileService.java @@ -0,0 +1,69 @@ +/* + * 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 org.apache.maven.api.services; + +import java.io.IOException; +import java.nio.file.Path; + +import org.apache.maven.api.Service; +import org.apache.maven.api.Session; +import org.apache.maven.api.annotations.Nonnull; + +/** + * Service to create and track temporary files/directories for a Maven build. + * All created paths are deleted automatically when the session ends. + */ +public interface TempFileService extends Service { + + /** + * Creates a temp file in the default temp directory. + */ + @Nonnull + Path createTempFile(Session session, String prefix, String suffix) throws IOException; + + /** + * Creates a temp file in the given directory. + */ + @Nonnull + Path createTempFile(Session session, String prefix, String suffix, Path directory) throws IOException; + + /** + * Creates a temp directory in the default temp directory. + */ + @Nonnull + Path createTempDirectory(Session session, String prefix) throws IOException; + + /** + * Creates a temp directory in the given directory. + */ + @Nonnull + Path createTempDirectory(Session session, String prefix, Path directory) throws IOException; + + /** + * Registers an externally created path for cleanup at session end. + */ + @Nonnull + void register(Session session, Path path); + + /** + * Forces cleanup for the given session (normally called by lifecycle). + */ + @Nonnull + void cleanup(Session session) throws IOException; +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultTempFileService.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultTempFileService.java new file mode 100644 index 000000000000..0496c3a9e6e7 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultTempFileService.java @@ -0,0 +1,173 @@ +/* + * 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 org.apache.maven.internal.impl; + +import javax.inject.Named; +import javax.inject.Singleton; + +import java.io.IOException; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.apache.maven.api.Session; +import org.apache.maven.api.SessionData; +import org.apache.maven.api.services.TempFileService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.api.Constants.KEEP_PROP; + +/** + * Default TempFileService implementation. + * Stores tracked paths in Session-scoped data and removes them after the build. + */ +@Named +@Singleton +public final class DefaultTempFileService implements TempFileService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultTempFileService.class); + + // unique, typed session key (uses factory; one narrow unchecked cast) + @SuppressWarnings({"unchecked", "rawtypes"}) + private static final SessionData.Key> TMP_KEY = + (SessionData.Key) SessionData.key(Set.class, DefaultTempFileService.class); + + // supplier with concrete types (avoids inference noise) + private static final Supplier> TMP_SUPPLIER = + () -> Collections.newSetFromMap(new ConcurrentHashMap()); + + @Override + public Path createTempFile(final Session session, final String prefix, final String suffix) throws IOException { + Objects.requireNonNull(session, "session"); + final Path file = Files.createTempFile(prefix, suffix); + register(session, file); + return file; + } + + @Override + public Path createTempFile(final Session session, final String prefix, final String suffix, final Path directory) + throws IOException { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(directory, "directory"); + final Path file = Files.createTempFile(directory, prefix, suffix); + register(session, file); + return file; + } + + @Override + public Path createTempDirectory(final Session session, final String prefix) throws IOException { + Objects.requireNonNull(session, "session"); + final Path dir = Files.createTempDirectory(prefix); + register(session, dir); + return dir; + } + + @Override + public Path createTempDirectory(final Session session, final String prefix, final Path directory) + throws IOException { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(directory, "directory"); + final Path dir = Files.createTempDirectory(directory, prefix); + register(session, dir); + return dir; + } + + @Override + public void register(final Session session, final Path path) { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(path, "path"); + final Set bucket = sessionPaths(session); + bucket.add(path); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Temp path registered for cleanup: {}", path); + } + } + + @Override + public void cleanup(final Session session) throws IOException { + Objects.requireNonNull(session, "session"); + + if (Boolean.getBoolean(KEEP_PROP)) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("Skipping temp cleanup due to -D{}=true", KEEP_PROP); + } + return; + } + + final Set bucket = sessionPaths(session); + IOException first = null; + + for (final Path path : bucket) { + try { + deleteTree(path); + } catch (final IOException e) { + if (first == null) { + first = e; + } else if (e != first) { + first.addSuppressed(e); + } + LOGGER.warn("Failed to delete temp path {}", path, e); + } + } + bucket.clear(); + + if (first != null) { + throw first; + } + } + + // ---- internals --------------------------------------------------------- + + private Set sessionPaths(final Session session) { + return session.getData().computeIfAbsent(TMP_KEY, TMP_SUPPLIER); + } + + private static void deleteTree(final Path path) throws IOException { + if (path == null || Files.notExists(path)) { + return; + } + // Walk depth-first and delete files, then directories. + Files.walkFileTree( + path, EnumSet.noneOf(FileVisitOption.class), Integer.MAX_VALUE, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) + throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) + throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/TempFileCleanupParticipant.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/TempFileCleanupParticipant.java new file mode 100644 index 000000000000..ea3398dc067a --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/TempFileCleanupParticipant.java @@ -0,0 +1,57 @@ +/* + * 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 org.apache.maven.internal.impl; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.api.Session; +import org.apache.maven.api.services.TempFileService; +import org.apache.maven.execution.MavenSession; + +/** + * Hooks into the Maven lifecycle and removes all temp material after the session. + */ +@Named +@Singleton +public final class TempFileCleanupParticipant extends AbstractMavenLifecycleParticipant { + + private final TempFileService tempFileService; + + @Inject + public TempFileCleanupParticipant(final TempFileService tempFileService) { + this.tempFileService = tempFileService; + } + + @Override + public void afterSessionEnd(final MavenSession mavenSession) { + // Bridge to the API Session (available in Maven 4). + final Session apiSession = mavenSession.getSession(); + try { + tempFileService.cleanup(apiSession); + } catch (final Exception e) { + // We’re at session end; just log. Maven already reported build result. + // Use slf4j directly to avoid throwing from the lifecycle callback. + org.slf4j.LoggerFactory.getLogger(TempFileCleanupParticipant.class) + .warn("Temp cleanup failed: {}", e.getMessage()); + } + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultTempFileServiceTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultTempFileServiceTest.java new file mode 100644 index 000000000000..1c1d4e58d4e1 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultTempFileServiceTest.java @@ -0,0 +1,154 @@ +/* + * 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 org.apache.maven.internal.impl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.apache.maven.api.Session; +import org.apache.maven.api.SessionData; +import org.apache.maven.api.services.TempFileService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for DefaultTempFileService. + */ +class DefaultTempFileServiceTest { + + private static final String KEEP_PROP = "maven.tempfile.keep"; + + @AfterEach + void clearKeepProp() { + System.clearProperty(KEEP_PROP); + } + + @Test + void createsFilesAndDirectoriesAndCleansThemUp() throws IOException { + final TempFileService svc = new DefaultTempFileService(); + final SessionData data = new MapBackedSessionData(); + final Session session = mock(Session.class); + when(session.getData()).thenReturn(data); + + final Path f1 = svc.createTempFile(session, "tfs-", ".bin"); + final Path d1 = svc.createTempDirectory(session, "tfs-"); + final Path nested = Files.createTempFile(d1, "inner-", ".tmp"); + + assertTrue(Files.exists(f1), "temp file must exist"); + assertTrue(Files.exists(d1), "temp dir must exist"); + assertTrue(Files.exists(nested), "nested file must exist"); + + svc.cleanup(session); + + assertFalse(Files.exists(f1), "temp file must be deleted"); + assertFalse(Files.exists(d1), "temp dir tree must be deleted"); + assertFalse(Files.exists(nested), "nested file must be deleted"); + } + + @Test + void registerExternalPathIsAlsoDeleted() throws IOException { + final TempFileService svc = new DefaultTempFileService(); + final SessionData data = new MapBackedSessionData(); + final Session session = mock(Session.class); + when(session.getData()).thenReturn(data); + + final Path externalDir = Files.createTempDirectory("ext-"); + final Path nested = Files.createTempFile(externalDir, "ext-inner-", ".tmp"); + assertTrue(Files.exists(externalDir)); + assertTrue(Files.exists(nested)); + + svc.register(session, externalDir); + svc.cleanup(session); + + assertFalse(Files.exists(externalDir), "registered external dir must be deleted recursively"); + assertFalse(Files.exists(nested)); + } + + @Test + void keepPropertySkipsCleanup() throws IOException { + final TempFileService svc = new DefaultTempFileService(); + final SessionData data = new MapBackedSessionData(); + final Session session = mock(Session.class); + when(session.getData()).thenReturn(data); + + final Path f = svc.createTempFile(session, "keep-", ".tmp"); + assertTrue(Files.exists(f)); + + System.setProperty(KEEP_PROP, "true"); + svc.cleanup(session); + + assertTrue(Files.exists(f), "cleanup must be skipped when -Dmaven.tempfile.keep=true"); + + // turn cleanup back on and verify it deletes + System.clearProperty(KEEP_PROP); + svc.cleanup(session); + assertFalse(Files.exists(f)); + } + + /** + * Minimal, thread-safe SessionData backed by a ConcurrentHashMap. + * Keeps generics safe at the call sites of DefaultTempFileService. + */ + static final class MapBackedSessionData implements SessionData { + private final ConcurrentHashMap, Object> map = new ConcurrentHashMap<>(); + + @Override + public void set(final Key key, final T value) { + Objects.requireNonNull(key, "key"); + if (value == null) { + map.remove(key); + } else { + map.put(key, value); + } + } + + @Override + public boolean replace(final Key key, final T oldValue, final T newValue) { + Objects.requireNonNull(key, "key"); + if (newValue == null) { + return map.remove(key, oldValue); + } + return map.replace(key, oldValue, newValue); + } + + @SuppressWarnings("unchecked") + @Override + public T get(final Key key) { + Objects.requireNonNull(key, "key"); + return (T) map.get(key); + } + + @SuppressWarnings("unchecked") + @Override + public T computeIfAbsent(final Key key, final Supplier supplier) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(supplier, "supplier"); + return (T) map.computeIfAbsent(key, k -> supplier.get()); + } + } +}