You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/16 18:48:51 UTC
(maven) branch master updated: Fix #13084: sandbox profile activation context for external model builds (#13112)
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/master by this push:
new 9f5f92fa1e Fix #13084: sandbox profile activation context for external model builds (#13112)
9f5f92fa1e is described below
commit 9f5f92fa1e47fbd6fd9bcb90bd8da97d59611962
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Wed Sep 16 20:48:45 2026 +0200
Fix #13084: sandbox profile activation context for external model builds (#13112)
Instead of blanket-filtering file and property profiles in external model
builds (dependency POMs, parent POMs, imported BOMs), evaluate them against
a sandboxed ProfileActivationContext that:
- Merges model properties into system property lookups (so POM-declared
<properties> drive property-activated profiles in dependency builds).
- Suppresses user properties (consumer -D flags must not activate
dependency profiles — they were not set for that artifact).
- Pre-filters file-activated profiles (returning false from exists() would
incorrectly activate <missing> profiles).
- Strips repository contributions from all active external profiles.
Both compat and impl stacks are updated identically. The key insight is that
model properties are merged into system properties inside the sandbox rather
than changing PropertyProfileActivator's lookup chain, to avoid unintended
activation when a project POM declares a property that coincidentally matches
a profile's activation condition.
Tests: ExternalModelProfileActivationTest (compat) and DefaultModelBuilderTest
(impl) cover file suppression, user-property suppression, system-property
preservation, POM-declared-property activation, negated-property default-on
preservation, and system-property opt-out of the default-on pattern.
---
.../services/model/ProfileActivationContext.java | 11 +
.../maven/model/building/DefaultModelBuilder.java | 99 ++++++++-
.../model/profile/ProfileActivationContext.java | 10 +
.../activation/PropertyProfileActivator.java | 5 +
.../ExternalModelProfileActivationTest.java | 247 ++++++++++++++++++---
.../maven/impl/model/DefaultModelBuilder.java | 48 ++--
.../model/DefaultProfileActivationContext.java | 98 ++++++++
.../model/profile/PropertyProfileActivator.java | 13 ++
.../maven/impl/model/DefaultModelBuilderTest.java | 55 ++++-
.../poms/factory/resolved-model-with-profiles.xml | 12 +
...MavenITmng8736ConcurrentFileActivationTest.java | 2 +-
11 files changed, 540 insertions(+), 60 deletions(-)
diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java
index c9e73242f6..e3cf95c811 100644
--- a/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java
+++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java
@@ -63,6 +63,11 @@ public interface ProfileActivationContext {
/**
* Gets the user property to use for interpolation and profile activation. The user properties have been
* configured directly by the user on his discretion, e.g. via the {@code -Dkey=value} parameter on the command line.
+ * <p>
+ * <b>In repository-resolved (external) model builds</b> (dependency POMs, parent POMs, imported BOMs),
+ * this always returns {@code null}. Consumer {@code -D} flags are not propagated to dependency profile
+ * activation: the consumer did not set those flags for the dependency, and doing so would make dependency
+ * resolution non-deterministic and open to accidental (or malicious) profile injection.
*
* @param key the name of the user property
* @return The user property for the specified key, or {@code null}.
@@ -72,6 +77,12 @@ public interface ProfileActivationContext {
/**
* Gets the model property to use for interpolation and profile activation.
+ * <p>
+ * These are the properties declared in the model's own {@code <properties>} section.
+ * They are part of the artifact's published identity and are always consulted for profile
+ * activation, including in repository-resolved (external) model builds. Unlike
+ * {@link #getUserProperty(String)}, these are never suppressed when evaluating dependency
+ * profiles.
*
* @param key the name of the model property
* @return The model property for the specified key, or {@code null};
diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java
index 2aefe7048c..97f4291ce9 100644
--- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java
+++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java
@@ -314,9 +314,11 @@ protected ModelBuildingResult build(ModelBuildingRequest request, Collection<Str
List<Profile> interpolatedProfiles = getInterpolatedProfiles(rawModel, profileActivationContext, problems);
tmpModel.setProfiles(interpolatedProfiles);
+ List<Profile> profilesToEvaluate =
+ externalModel ? withoutFileActivation(tmpModel.getProfiles()) : tmpModel.getProfiles();
List<Profile> activePomProfiles = profileSelector.getActiveProfiles(
- externalModel ? withoutFileAndPropertyActivation(interpolatedProfiles) : tmpModel.getProfiles(),
- profileActivationContext,
+ profilesToEvaluate,
+ externalModel ? externalActivationContext(profileActivationContext) : profileActivationContext,
problems);
List<Profile> rawProfiles = new ArrayList<>();
@@ -514,16 +516,97 @@ private static boolean isExternalModelBuildingRequest(ModelBuildingRequest reque
}
/**
- * Returns the profiles from the given list whose activation does not depend on a file or a
- * property. Profiles activated by JDK version, operating system, or marked
- * {@code activeByDefault} are unaffected, since those conditions are a function of the build
- * platform rather than of the model content.
+ * Returns a sandboxed {@link ProfileActivationContext} for evaluating profiles in
+ * repository-resolved (external) models — dependency POMs, parent POMs, and imported BOMs.
+ * <p>
+ * The sandboxed context preserves system properties (so JDK/OS activation works) and
+ * merges the POM's own {@code <properties>} into the system properties map so that
+ * property-activated profiles that depend on POM-declared values still work.
+ * User properties (consumer {@code -D} flags) are suppressed because they were not
+ * set for the dependency and must not accidentally activate its profiles.
+ * File-based profiles are pre-filtered via {@link #withoutFileActivation(List)} before
+ * reaching this context, so {@code getProjectDirectory()} is not relied on for file checks.
+ * <p>
+ * Model properties are merged into system properties (with system properties taking
+ * precedence) rather than changing the {@code PropertyProfileActivator} lookup chain,
+ * because changing the activator would affect ALL profile evaluations — including the
+ * build's own project — which can cause unintended profile activation when a POM declares
+ * a property that matches a profile's activation condition.
+ *
+ * @param delegate the original full context for this model build
+ * @return a sandboxed context suitable for external model profile activation
*/
- private static List<Profile> withoutFileAndPropertyActivation(List<Profile> profiles) {
+ private static ProfileActivationContext externalActivationContext(ProfileActivationContext delegate) {
+ return new ProfileActivationContext() {
+ /**
+ * Cached merged map of project + system properties (computed once on first access).
+ * System properties win on conflict. {@code null} means not yet computed.
+ */
+ private Map<String, String> mergedSystemProperties;
+
+ @Override
+ public List<String> getActiveProfileIds() {
+ return delegate.getActiveProfileIds();
+ }
+
+ @Override
+ public List<String> getInactiveProfileIds() {
+ return delegate.getInactiveProfileIds();
+ }
+
+ /**
+ * System properties merged with project properties (system wins on conflict).
+ * This makes POM-declared properties visible to the PropertyProfileActivator
+ * without modifying the activator's lookup chain for non-external models.
+ * The merged map is computed once and cached to avoid repeated allocations.
+ */
+ @Override
+ public Map<String, String> getSystemProperties() {
+ if (mergedSystemProperties != null) {
+ return mergedSystemProperties;
+ }
+ Map<String, String> projectProps = delegate.getProjectProperties();
+ if (projectProps == null || projectProps.isEmpty()) {
+ return delegate.getSystemProperties();
+ }
+ Map<String, String> merged = new HashMap<>(projectProps);
+ merged.putAll(delegate.getSystemProperties()); // system wins
+ mergedSystemProperties = merged;
+ return mergedSystemProperties;
+ }
+
+ /** User properties are suppressed: consumer -D flags do not activate dependency profiles. */
+ @Override
+ public Map<String, String> getUserProperties() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Map<String, String> getProjectProperties() {
+ return delegate.getProjectProperties();
+ }
+
+ @Override
+ public File getProjectDirectory() {
+ return delegate.getProjectDirectory();
+ }
+ };
+ }
+
+ /**
+ * Returns the profiles from the given list whose activation does not depend on a file.
+ * File-activated profiles are suppressed in external model builds because publisher-local
+ * paths do not exist in the consumer's environment and resolving them against the consumer's
+ * filesystem would produce non-deterministic or incorrect results.
+ *
+ * @param profiles the full profile list
+ * @return profiles with file-activated ones removed
+ */
+ private static List<Profile> withoutFileActivation(List<Profile> profiles) {
List<Profile> eligible = new ArrayList<>(profiles.size());
for (Profile profile : profiles) {
Activation activation = profile.getActivation();
- if (activation == null || (activation.getFile() == null && activation.getProperty() == null)) {
+ if (activation == null || activation.getFile() == null) {
eligible.add(profile);
}
}
diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java
index cb529cd0e4..ccb5bdc9a3 100644
--- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java
+++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java
@@ -62,6 +62,11 @@ public interface ProfileActivationContext {
* Gets the user properties to use for interpolation and profile activation. The user properties have been
* configured directly by the user on his discretion, e.g. via the {@code -Dkey=value} parameter on the command
* line.
+ * <p>
+ * <b>In repository-resolved (external) model builds</b> (dependency POMs, parent POMs, imported BOMs), this
+ * map is intentionally empty. Consumer {@code -D} flags are not propagated to dependency profile activation:
+ * the consumer did not set those flags for the dependency, and doing so would make dependency resolution
+ * non-deterministic and open to accidental (or malicious) profile injection.
*
* @return The user properties, never {@code null}.
*/
@@ -76,6 +81,11 @@ public interface ProfileActivationContext {
/**
* Gets current calculated project properties
+ * <p>
+ * These are the properties declared in the model's own {@code <properties>} section.
+ * They are part of the artifact's published identity and are consulted for profile activation
+ * in all contexts, including repository-resolved (external) model builds. Unlike
+ * {@link #getUserProperties()}, these are never suppressed when evaluating dependency profiles.
*
* @return The project properties, never {@code null}.
*/
diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java
index 9873874b93..3b4bfc8da8 100644
--- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java
+++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java
@@ -71,6 +71,11 @@ public boolean isActive(Profile profile, ProfileActivationContext context, Model
return false;
}
+ // Lookup order: user (-D) → system (java.version, os.name, …).
+ // In external model builds the caller provides a sandboxed context
+ // (see DefaultModelBuilder.externalActivationContext()) that suppresses user properties
+ // and merges model properties into system properties, so consumer -D flags cannot
+ // activate dependency profiles while POM-declared properties still drive activation.
String sysValue = context.getUserProperties().get(name);
if (sysValue == null) {
sysValue = context.getSystemProperties().get(name);
diff --git a/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java b/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java
index 43c51162e4..12304b26af 100644
--- a/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java
+++ b/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java
@@ -28,26 +28,53 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * Models built at {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} come from POMs resolved
- * from a repository during dependency resolution (a dependency POM, one of its parents, or an
- * imported BOM), see for instance {@code DefaultArtifactDescriptorReader#loadPom}. Their file and
- * property activators are not evaluated. A project build, at
- * {@link ModelBuildingRequest#VALIDATION_LEVEL_STRICT}, still evaluates every activator.
- * Platform-derived activation (JDK version, operating system, activeByDefault) is unaffected at
- * either level. Repositories declared in legitimately-active profiles (JDK/OS/activeByDefault) are
- * honored in external model builds — stripping them would break the established
- * {@code project → dep1 → dep2} pattern where dep1 declares dep2's non-Central repository inside
- * a JDK- or activeByDefault-activated profile.
+ * Tests for profile activation in external (repository-resolved) model builds, i.e. those at
+ * {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} (dependency POMs, parent POMs, imported BOMs).
+ *
+ * <h2>Activation rules for external models</h2>
+ * <ul>
+ * <li><b>JDK / OS / activeByDefault</b> — always re-activated: they are platform facts.</li>
+ * <li><b>File</b> — suppressed: publisher-local paths don't exist on the consumer's machine.</li>
+ * <li><b>Property (user / -D)</b> — suppressed: consumer flags were not set for that dependency
+ * and must not accidentally activate its profiles.</li>
+ * <li><b>Property (system)</b> — re-activated: system properties are platform facts
+ * ({@code java.version}, {@code os.name}, …).</li>
+ * <li><b>Property (POM-declared, from <properties>)</b> — re-activated: the POM's own
+ * properties are part of the artifact's published identity, not the consumer's environment.
+ * A profile conditioned on a POM-declared property is publisher-intent, not consumer-injected.</li>
+ * <li><b>Negated property (-D absent)</b> — re-activated (default-on): the property is absent from
+ * the sandboxed context, so {@code !foo} evaluates to true, preserving the publisher's intent.</li>
+ * <li><b>Profile repositories</b> — honored from legitimately-active profiles (JDK/OS/activeByDefault
+ * and POM-property-gated); stripping them would break the established
+ * {@code project → dep1 → dep2} pattern. See #13100.</li>
+ * </ul>
*/
class ExternalModelProfileActivationTest {
+ /**
+ * POM with profiles covering all activation types.
+ * <p>
+ * For the file test, {@code some.dir} is set as a system property pointing to a directory that
+ * exists (tmpdir); in a STRICT project build this profile fires, but in external builds file
+ * activation is suppressed entirely.
+ * <p>
+ * For the user-property test, {@code user.gating.prop} must be set via user properties (-D),
+ * not system properties, to test the suppression of -D flags in external builds.
+ * <p>
+ * For the system-property test, {@code sys.gating.prop} is set as a system property and
+ * must remain effective in external builds (system properties are platform facts).
+ */
private static final String POM = "<project>\n"
+ " <modelVersion>4.0.0</modelVersion>\n"
+ " <groupId>thegroup</groupId>\n"
+ " <artifactId>withprofiles</artifactId>\n"
+ " <version>1</version>\n"
+ " <packaging>pom</packaging>\n"
+ + " <properties>\n"
+ + " <pom.declared.prop>hello</pom.declared.prop>\n"
+ + " </properties>\n"
+ " <profiles>\n"
+ // profile 1: file activation — suppressed in external builds
+ " <profile>\n"
+ " <id>file-condition</id>\n"
+ " <activation>\n"
@@ -59,17 +86,55 @@ class ExternalModelProfileActivationTest {
+ " <profile.file>activated</profile.file>\n"
+ " </properties>\n"
+ " </profile>\n"
+ // profile 2: user-property activation — suppressed in external builds
+ + " <profile>\n"
+ + " <id>user-property-condition</id>\n"
+ + " <activation>\n"
+ + " <property>\n"
+ + " <name>user.gating.prop</name>\n"
+ + " </property>\n"
+ + " </activation>\n"
+ + " <properties>\n"
+ + " <profile.user.property>activated</profile.user.property>\n"
+ + " </properties>\n"
+ + " </profile>\n"
+ // profile 3: system-property activation — must fire in external builds
+ + " <profile>\n"
+ + " <id>system-property-condition</id>\n"
+ + " <activation>\n"
+ + " <property>\n"
+ + " <name>sys.gating.prop</name>\n"
+ + " </property>\n"
+ + " </activation>\n"
+ + " <properties>\n"
+ + " <profile.sys.property>activated</profile.sys.property>\n"
+ + " </properties>\n"
+ + " </profile>\n"
+ // profile 4: POM-declared property activation (positive) — must fire in external builds
+ + " <profile>\n"
+ + " <id>pom-declared-property-condition</id>\n"
+ + " <activation>\n"
+ + " <property>\n"
+ + " <name>pom.declared.prop</name>\n"
+ + " </property>\n"
+ + " </activation>\n"
+ + " <properties>\n"
+ + " <profile.pom.property>activated</profile.pom.property>\n"
+ + " </properties>\n"
+ + " </profile>\n"
+ // profile 5: negated-property (default-on, opt-out via -D) — must fire in external builds
+ " <profile>\n"
- + " <id>property-condition</id>\n"
+ + " <id>negated-property-default-on</id>\n"
+ " <activation>\n"
+ " <property>\n"
- + " <name>some.gating.property</name>\n"
+ + " <name>!skip.defaults</name>\n"
+ " </property>\n"
+ " </activation>\n"
+ " <properties>\n"
- + " <profile.property>activated</profile.property>\n"
+ + " <profile.negated.property>activated</profile.negated.property>\n"
+ " </properties>\n"
+ " </profile>\n"
+ // profile 6: JDK activation — always fires
+ " <profile>\n"
+ " <id>jdk-condition</id>\n"
+ " <activation>\n"
@@ -88,42 +153,164 @@ class ExternalModelProfileActivationTest {
+ " </profiles>\n"
+ "</project>\n";
- private Model build(int validationLevel) throws Exception {
+ private Model build(int validationLevel, Properties systemProperties, Properties userProperties) throws Exception {
ModelBuilder builder = new DefaultModelBuilderFactory().newInstance();
- Properties systemProperties = new Properties();
- systemProperties.putAll(System.getProperties());
- systemProperties.setProperty("some.dir", System.getProperty("java.io.tmpdir"));
- systemProperties.setProperty("some.gating.property", "true");
-
DefaultModelBuildingRequest request = new DefaultModelBuildingRequest();
request.setModelSource(new StringModelSource(POM));
request.setValidationLevel(validationLevel);
request.setSystemProperties(systemProperties);
+ request.setUserProperties(userProperties);
+ request.setModelResolver(new DefaultModelBuilderTest.BaseModelResolver());
return builder.build(request).getEffectiveModel();
}
+ private Properties systemPropertiesWithTestValues() {
+ Properties sp = new Properties();
+ sp.putAll(System.getProperties());
+ sp.setProperty("some.dir", System.getProperty("java.io.tmpdir"));
+ sp.setProperty("sys.gating.prop", "true");
+ return sp;
+ }
+
+ private Properties userPropertiesWithTestValues() {
+ Properties up = new Properties();
+ up.setProperty("user.gating.prop", "true");
+ return up;
+ }
+
@Test
void testProjectBuildEvaluatesAllActivators() throws Exception {
- Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_STRICT);
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_STRICT,
+ systemPropertiesWithTestValues(),
+ userPropertiesWithTestValues());
+
+ assertEquals("activated", model.getProperties().get("profile.file"), "file profile must fire in project build");
+ assertEquals(
+ "activated",
+ model.getProperties().get("profile.user.property"),
+ "user-property profile must fire in project build");
+ assertEquals(
+ "activated",
+ model.getProperties().get("profile.sys.property"),
+ "system-property profile must fire in project build");
+ // POM-declared property activation is only supported in external (sandbox) builds.
+ // In project builds, the PropertyProfileActivator does not check model properties
+ // because doing so would cause unintended profile activation when a POM declares
+ // a property that also matches a profile's activation condition (see IT proxy profile).
+ assertNull(
+ model.getProperties().get("profile.pom.property"),
+ "POM-declared-property profile must NOT fire in project build (only in external)");
+ assertEquals(
+ "activated",
+ model.getProperties().get("profile.negated.property"),
+ "negated-property profile must fire in project build");
+ assertEquals("activated", model.getProperties().get("profile.jdk"), "JDK profile must fire in project build");
+ assertTrue(
+ model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())),
+ "profile repository must be present in project build");
+ }
+
+ @Test
+ void testExternalModelFileActivationIsSuppressed() throws Exception {
+ // File activation must be suppressed in external model builds regardless of whether the
+ // path exists: publisher-local paths don't exist on the consumer's machine.
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL,
+ systemPropertiesWithTestValues(),
+ userPropertiesWithTestValues());
+
+ assertNull(model.getProperties().get("profile.file"), "file profile must NOT fire in external model build");
+ }
+
+ @Test
+ void testExternalModelUserPropertyActivationIsSuppressed() throws Exception {
+ // Consumer -D flags must not activate dependency profiles.
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL,
+ systemPropertiesWithTestValues(),
+ userPropertiesWithTestValues());
+
+ assertNull(
+ model.getProperties().get("profile.user.property"),
+ "user-property profile must NOT fire in external model build (consumer -D suppressed)");
+ }
+
+ @Test
+ void testExternalModelSystemPropertyActivationIsPreserved() throws Exception {
+ // System properties are platform facts and must drive activation even in external builds.
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+ assertEquals(
+ "activated",
+ model.getProperties().get("profile.sys.property"),
+ "system-property profile must fire in external model build");
+ }
+
+ @Test
+ void testExternalModelPomDeclaredPropertyActivationIsPreserved() throws Exception {
+ // A profile conditioned on the POM's own <properties> must fire in external builds:
+ // those properties are part of the artifact's published identity, not the consumer's env.
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+ assertEquals(
+ "activated",
+ model.getProperties().get("profile.pom.property"),
+ "POM-declared-property profile must fire in external model build");
+ }
+
+ @Test
+ void testExternalModelNegatedPropertyDefaultOnIsPreserved() throws Exception {
+ // A negated-property profile (!foo) fires when the property is absent.
+ // In the sandbox, user properties are suppressed so 'skip.defaults' is absent → fires.
+ // This is the common "opt-out flag" pattern (e.g. resteasy-default in JBoss projects).
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+ assertEquals(
+ "activated",
+ model.getProperties().get("profile.negated.property"),
+ "negated-property (default-on) profile must fire in external model build");
+ }
+
+ @Test
+ void testExternalModelSystemPropertySuppressesDefaultOnProfile() throws Exception {
+ // A system property named skip.defaults suppresses the default-on negated-property
+ // profile (!skip.defaults) even in external model builds. System properties are platform
+ // facts that pass through the sandbox unchanged, so the publisher's opt-out mechanism
+ // still works when the consumer sets it at the JVM level.
+ Properties sp = systemPropertiesWithTestValues();
+ sp.setProperty("skip.defaults", "true"); // system-level opt-out
+ Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, sp, new Properties());
- assertEquals("activated", model.getProperties().get("profile.file"));
- assertEquals("activated", model.getProperties().get("profile.property"));
- assertEquals("activated", model.getProperties().get("profile.jdk"));
- assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
+ assertNull(
+ model.getProperties().get("profile.negated.property"),
+ "negated-property (default-on) profile must be suppressed when system property 'skip.defaults' is set");
}
@Test
- void testDependencyPomHonorsJdkActivatedProfileRepositories() throws Exception {
- Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL);
+ void testExternalModelJdkActivationIsPreserved() throws Exception {
+ // JDK activation is a platform fact and must fire in external builds.
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+ assertEquals("activated", model.getProperties().get("profile.jdk"), "JDK profile must fire in external build");
+ }
- assertNull(model.getProperties().get("profile.file"));
- assertNull(model.getProperties().get("profile.property"));
- assertEquals("activated", model.getProperties().get("profile.jdk"));
+ @Test
+ void testExternalModelJdkProfileRepositoriesAreHonored() throws Exception {
// Repositories from legitimately-active profiles (JDK-activated) must be honored:
// stripping them would break the project → dep1 → dep2 pattern. See #13100, #13116.
- assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
+ Model model = build(
+ ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+ assertTrue(
+ model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())),
+ "profile repository must be present in external model build (JDK-activated profile)");
}
/**
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
index 3ed4f89c0a..d698c5dddb 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
@@ -109,6 +109,7 @@
import org.apache.maven.api.services.model.ModelVersionParser;
import org.apache.maven.api.services.model.PluginConfigurationExpander;
import org.apache.maven.api.services.model.PluginManagementInjector;
+import org.apache.maven.api.services.model.ProfileActivationContext;
import org.apache.maven.api.services.model.ProfileInjector;
import org.apache.maven.api.services.model.ProfileSelector;
import org.apache.maven.api.services.model.RootLocator;
@@ -1648,25 +1649,37 @@ private void addActivePomProfiles(String modelId, List<Profile> activePomProfile
private List<Profile> getActiveProfiles(
Collection<Profile> interpolatedProfiles, DefaultProfileActivationContext profileActivationContext) {
if (isBuildRequestWithActivation()) {
- Collection<Profile> eligibleProfiles = interpolatedProfiles;
if (externalOrigin) {
// A model resolved to satisfy dependency resolution -- a dependency POM
- // itself, or one of its parents, reached transitively -- evaluates only
- // platform-derived activation (JDK version, operating system,
- // activeByDefault). Repository stripping is intentionally omitted here:
- // file and property activation is already excluded by the
- // hasFileOrPropertyOrConditionActivation filter below, so only
- // legitimately-active profiles reach injection. Stripping their
- // repositories would break the established project → dep1 → dep2 pattern
- // where dep1 declares dep2's repository inside an activeByDefault or
- // JDK-activated profile.
- eligibleProfiles = interpolatedProfiles.stream()
- .filter(profile -> !hasFileOrPropertyOrConditionActivation(profile))
+ // itself, or one of its parents, reached transitively -- evaluates profiles
+ // against a sandboxed activation context. The sandbox:
+ // - merges model properties into system property lookups (so POM-declared
+ // <properties> drive activation via the existing property lookup chain),
+ // - suppresses user properties (consumer -D flags must not activate
+ // dependency profiles — they were not set for that artifact),
+ // - disables file existence checks (publisher paths don't exist here).
+ // Model properties are merged into system properties rather than adding
+ // a separate lookup step in PropertyProfileActivator, because changing the
+ // activator would affect ALL profile evaluations (including the build's own
+ // project) and cause unintended profile activation when a POM declares a
+ // property that matches a profile's activation condition.
+ // File-activated profiles are pre-filtered (not just sandboxed) because
+ // returning false from exists() would incorrectly activate <missing> profiles.
+ // Repository stripping is intentionally omitted: only legitimately-active
+ // profiles (JDK/OS/activeByDefault and POM-property-gated) reach injection,
+ // and stripping their repositories would break the established
+ // project → dep1 → dep2 pattern. See #13100.
+ // TODO(#13146): repositories contributed by external-model profiles can shadow
+ // central; a WARN/FAIL policy for URL mismatches should be added separately.
+ Collection<Profile> nonFileProfiles = interpolatedProfiles.stream()
+ .filter(p -> p.getActivation() == null
+ || p.getActivation().getFile() == null)
.toList();
+ ProfileActivationContext externalContext =
+ profileActivationContext.withoutUserPropertiesAndFilesystem();
+ return profileSelector.getActiveProfiles(nonFileProfiles, externalContext, this);
}
- // TODO(#13146): repositories contributed by external-model profiles can shadow
- // central; a WARN/FAIL policy for URL mismatches should be added separately.
- return profileSelector.getActiveProfiles(eligibleProfiles, profileActivationContext, this);
+ return profileSelector.getActiveProfiles(interpolatedProfiles, profileActivationContext, this);
} else {
// BUILD_CONSUMER: activate only deterministic profiles whose activation is a
// function of the build platform (OS, JDK version, activeByDefault) rather than
@@ -1692,6 +1705,10 @@ private List<Profile> getActiveProfiles(
* Determines whether the given profile's activation depends on file existence, a
* property, or a condition expression, as opposed to being a function of the build
* platform (JDK version, operating system) or {@code activeByDefault}.
+ * <p>
+ * Used for BUILD_CONSUMER model building to keep the consumer POM deterministic.
+ * For external (dependency/parent/BOM) models, the sandbox context approach is used
+ * instead — see {@link DefaultProfileActivationContext#withoutUserPropertiesAndFilesystem()}.
*/
private static boolean hasFileOrPropertyOrConditionActivation(Profile profile) {
Activation activation = profile.getActivation();
@@ -1703,7 +1720,6 @@ private static boolean hasFileOrPropertyOrConditionActivation(Profile profile) {
}
/**
- * Determines whether the given profile's activation includes a packaging condition.
* Packaging-activated profiles are handled separately by the consumer POM builder's
* {@code inlinePackagingActivatedProfiles()} and must not be activated during
* BUILD_CONSUMER model building to avoid double-merging their contributions.
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java
index 08cef4afb6..dafa7f19cb 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java
@@ -355,6 +355,104 @@ public DefaultProfileActivationContext setModel(Model model) {
return this;
}
+ /**
+ * Returns a sandboxed copy of this context suitable for evaluating profiles in
+ * repository-resolved (external) models — dependency POMs, parent POMs, and imported BOMs.
+ * <p>
+ * The sandboxed context:
+ * <ul>
+ * <li><b>Merges model properties into system property lookups</b>: system properties
+ * ({@code java.version}, {@code os.name}, …) are checked first (preserving JDK/OS
+ * activation), then model properties (the POM's own {@code <properties>}) are used
+ * as a fallback. This makes POM-declared properties visible to the
+ * {@code PropertyProfileActivator} without modifying the activator's lookup chain
+ * for non-external models.</li>
+ * <li><b>Suppresses user properties</b> (consumer {@code -D} flags): they were not
+ * set for the dependency and must not accidentally activate its profiles.</li>
+ * <li><b>Disables file existence checks</b>: the publisher's file system paths do not
+ * exist in the consumer's environment. File-activated profiles should be
+ * pre-filtered by the caller before reaching this context (the sandbox's
+ * {@code exists()} returns {@code false} as a safety net, but that alone would
+ * incorrectly activate {@code <missing>} profiles).</li>
+ * </ul>
+ *
+ * @return a sandboxed {@link ProfileActivationContext} for external model evaluation
+ */
+ public ProfileActivationContext withoutUserPropertiesAndFilesystem() {
+ return new ProfileActivationContext() {
+ @Override
+ public boolean isProfileActive(String profileId) {
+ return DefaultProfileActivationContext.this.isProfileActive(profileId);
+ }
+
+ @Override
+ public boolean isProfileInactive(String profileId) {
+ return DefaultProfileActivationContext.this.isProfileInactive(profileId);
+ }
+
+ /**
+ * System property lookup with model property fallback.
+ * System properties take precedence; model properties fill in as a fallback
+ * so POM-declared values drive property-activated profiles in external models.
+ */
+ @Override
+ public String getSystemProperty(String key) {
+ String value = DefaultProfileActivationContext.this.getSystemProperty(key);
+ if (value == null) {
+ value = DefaultProfileActivationContext.this.getModelProperty(key);
+ }
+ return value;
+ }
+
+ /** User properties are suppressed: consumer {@code -D} flags do not activate dependency profiles. */
+ @Override
+ public String getUserProperty(String key) {
+ return null;
+ }
+
+ @Override
+ public String getModelProperty(String key) {
+ return DefaultProfileActivationContext.this.getModelProperty(key);
+ }
+
+ @Override
+ public String getModelArtifactId() {
+ return DefaultProfileActivationContext.this.getModelArtifactId();
+ }
+
+ @Override
+ public String getModelPackaging() {
+ return DefaultProfileActivationContext.this.getModelPackaging();
+ }
+
+ @Override
+ public String getModelRootDirectory() {
+ return DefaultProfileActivationContext.this.getModelRootDirectory();
+ }
+
+ @Override
+ public String getModelBaseDirectory() {
+ return DefaultProfileActivationContext.this.getModelBaseDirectory();
+ }
+
+ @Override
+ public String interpolatePath(String path) throws InterpolatorException {
+ return DefaultProfileActivationContext.this.interpolatePath(path);
+ }
+
+ /**
+ * File existence checks are disabled for external models: publisher paths do not
+ * exist in the consumer's environment. File-activated profiles are pre-filtered
+ * before reaching this context (see {@code getActiveProfiles}), so this method
+ * should not normally be called; it returns {@code false} as a safety net.
+ */
+ @Override
+ public boolean exists(String path, boolean glob) {
+ return false;
+ }
+ };
+ }
+
@Override
public String interpolatePath(String path) throws InterpolatorException {
if (path == null) {
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java
index f83f7ab072..e29100ae7d 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java
@@ -35,6 +35,14 @@
/**
* Determines profile activation based on the existence or value of some execution property.
+ * <p>
+ * The property lookup order is: user properties ({@code -D} flags) → system properties
+ * ({@code java.version}, {@code os.name}, …). In external model builds (dependency POMs,
+ * parent POMs, imported BOMs), the caller provides a sandboxed {@link ProfileActivationContext}
+ * that suppresses user properties and merges model properties into the system properties map,
+ * so only platform and POM-declared properties drive activation. This makes profile
+ * activation in published artifacts deterministic: it depends on the consumer's platform
+ * and the artifact's declared properties, not on the consumer's {@code -D} flags.
*
* @see ActivationProperty
*/
@@ -80,6 +88,11 @@ public boolean isActive(Profile profile, ProfileActivationContext context, Model
return false;
}
+ // Lookup order: user (-D) → system (java.version, os.name, …).
+ // In external model builds the caller provides a sandboxed context that suppresses
+ // user properties and merges model properties into system properties, so consumer
+ // -D flags cannot activate dependency profiles while POM-declared properties still
+ // drive activation.
String sysValue = context.getUserProperty(name);
if (sysValue == null && "packaging".equals(name)) {
sysValue = context.getModelPackaging();
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
index c5dc75d1ef..97ed7bcc3b 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
@@ -145,12 +145,17 @@ private ModelBuilderRequest.ModelBuilderRequestBuilder resolvedProfilesRequest(
systemProperties.put(name, System.getProperty(name));
}
systemProperties.put("some.dir", System.getProperty("java.io.tmpdir"));
- systemProperties.put("some.gating.property", "true");
- systemProperties.put("some.condition.property", "true");
+ // Use user properties for gating values: they simulate -D flags and must be suppressed
+ // when building external (repository-resolved) models. System properties are reserved for
+ // platform facts (java.version, os.name, …) that survive the external-model sandbox.
+ Map<String, String> userProperties = Map.of(
+ "some.gating.property", "true",
+ "some.condition.property", "true");
return ModelBuilderRequest.builder()
.session(session)
.requestType(requestType)
.systemProperties(systemProperties)
+ .userProperties(userProperties)
.source(Sources.buildSource(getPom("resolved-model-with-profiles")));
}
@@ -164,6 +169,7 @@ public void testProjectBuildEvaluatesAllActivators() {
assertEquals("activated", model.getProperties().get("profile.property"));
assertEquals("activated", model.getProperties().get("profile.condition"));
assertEquals("activated", model.getProperties().get("profile.jdk"));
+ assertEquals("activated", model.getProperties().get("profile.negated.property"));
assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
}
@@ -180,6 +186,11 @@ public void testDependencyModelActivatesOnlyEnvironmentIndependentProfiles() {
assertNull(model.getProperties().get("profile.property"));
assertNull(model.getProperties().get("profile.condition"));
assertEquals("activated", model.getProperties().get("profile.jdk"));
+ // Negated-property profile (!skip.defaults) fires because 'skip.defaults' is absent
+ // from the sandboxed context (user properties are suppressed; system properties do not
+ // contain it). This is the common opt-out flag pattern: the publisher's default-on
+ // profile must remain active for external builds unless a system-level override is set.
+ assertEquals("activated", model.getProperties().get("profile.negated.property"));
// Repositories from legitimately-active profiles (JDK-activated) must be honored:
// stripping them would break the project → dep1 → dep2 pattern. See #13100, #13116.
assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
@@ -208,6 +219,36 @@ public void testActiveByDefaultProfileRepositoryHonored() {
"Repository from activeByDefault profile must be retained in external dependency model");
}
+ /**
+ * A system property named {@code skip.defaults} suppresses the default-on negated-property
+ * profile ({@code !skip.defaults}) even in external (repository-resolved) model builds.
+ * System properties are platform facts that pass through the sandbox unchanged, so the
+ * publisher's opt-out mechanism still works when the consumer sets it at the JVM level.
+ */
+ @Test
+ public void testExternalModelSystemPropertySuppressesDefaultOnProfile() {
+ Map<String, String> systemProperties = new HashMap<>();
+ for (String name : System.getProperties().stringPropertyNames()) {
+ systemProperties.put(name, System.getProperty(name));
+ }
+ systemProperties.put("some.dir", System.getProperty("java.io.tmpdir"));
+ systemProperties.put("skip.defaults", "true"); // system-level opt-out
+ ModelBuilderRequest request = ModelBuilderRequest.builder()
+ .session(session)
+ .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY)
+ .systemProperties(systemProperties)
+ .userProperties(Map.of("some.gating.property", "true", "some.condition.property", "true"))
+ .source(Sources.resolvedSource(
+ getPom("resolved-model-with-profiles"),
+ "org.apache.maven.test:resolved-model-with-profiles:1.0.0"))
+ .build();
+ Model model = builder.newSession().build(request).getEffectiveModel();
+
+ assertNull(
+ model.getProperties().get("profile.negated.property"),
+ "negated-property (default-on) profile must be suppressed when system property 'skip.defaults' is set");
+ }
+
/**
* A model built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} whose source
* is one Maven was merely pointed at -- {@link Sources#buildSource} rather than a source
@@ -224,6 +265,7 @@ public void testCallerSuppliedModelActivatesAllProfiles() {
assertEquals("activated", model.getProperties().get("profile.property"));
assertEquals("activated", model.getProperties().get("profile.condition"));
assertEquals("activated", model.getProperties().get("profile.jdk"));
+ assertEquals("activated", model.getProperties().get("profile.negated.property"));
assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
}
@@ -233,8 +275,9 @@ private Map<String, String> parentActivationSystemProperties() {
systemProperties.put(name, System.getProperty(name));
}
systemProperties.put("some.dir", System.getProperty("java.io.tmpdir"));
- systemProperties.put("some.gating.property", "true");
- systemProperties.put("some.condition.property", "true");
+ // Note: some.gating.property and some.condition.property are simulated -D flags;
+ // they belong in userProperties, not systemProperties, so that the external-model
+ // sandbox correctly suppresses them when building repository-resolved models.
return systemProperties;
}
@@ -246,7 +289,9 @@ private DefaultProfileActivationContext parentActivationContext(Map<String, Stri
List.of(),
List.of(),
systemProperties,
- Map.of(),
+ // Gating props simulate -D flags: pass as user properties so the external-model
+ // sandbox suppresses them for dependency parents but allows them for project parents.
+ Map.of("some.gating.property", "true", "some.condition.property", "true"),
Model.newInstance());
}
diff --git a/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml b/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml
index 46a923eba6..e3c53aefed 100644
--- a/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml
+++ b/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml
@@ -52,6 +52,18 @@
<profile.condition>activated</profile.condition>
</properties>
</profile>
+ <!-- default-on profile: fires when 'skip.defaults' is absent; suppressed when set as system property -->
+ <profile>
+ <id>negated-property-default-on</id>
+ <activation>
+ <property>
+ <name>!skip.defaults</name>
+ </property>
+ </activation>
+ <properties>
+ <profile.negated.property>activated</profile.negated.property>
+ </properties>
+ </profile>
<profile>
<id>jdk-condition</id>
<activation>
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8736ConcurrentFileActivationTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8736ConcurrentFileActivationTest.java
index 4f6a1b296f..07d7ae5e6c 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8736ConcurrentFileActivationTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8736ConcurrentFileActivationTest.java
@@ -51,7 +51,7 @@ void testConcurrentFileActivation() throws Exception {
verifier.addCliArgument("4");
verifier.addCliArgument("-Dmaven.modelBuilder.parallelism=4"); // Use 4 threads for concurrent execution
// verifier.addCliArgument("-X"); // Enable debug logging to see detailed traces
- verifier.addCliArgument("help:active-profiles");
+ verifier.addCliArgument("org.apache.maven.plugins:maven-help-plugin:3.5.1:active-profiles");
verifier.execute();
verifier.verifyErrorFreeLog();