From da5a28cd28ba9ec72f579967a358bd2e029fc8bd Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 15:56:53 +0200 Subject: [PATCH 01/14] Merge repositories from resolved dependency POMs recessively Repositories contributed by a model resolved from a repository are merged recessively; repositories supplied by the request or session keep precedence. Gates the replace branch of ModelBuilderSessionState.mergeRepositories() on isBuildRequest(), so only filesystem/project models (BUILD_PROJECT, BUILD_EFFECTIVE, BUILD_CONSUMER) retain replace semantics. --- .../maven/impl/model/DefaultModelBuilder.java | 4 +- .../impl/model/DefaultModelBuilderTest.java | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) 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 118e098d9cc8..6b52574c409f 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 @@ -639,7 +639,9 @@ public void mergeRepositories(Model model, boolean replace) { && (repo.getId() == null || !repo.getId().contains("${"))) .map(session::createRemoteRepository) .toList(); - if (replace) { + // Repositories contributed by a model resolved from a repository are merged + // recessively; repositories supplied by the request or session keep precedence. + if (replace && isBuildRequest()) { Set ids = repos.stream().map(RemoteRepository::getId).collect(Collectors.toSet()); repositories = repositories.stream() .filter(r -> !ids.contains(r.getId())) 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 f865beec06ad..8eb73a6ad16f 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 @@ -269,6 +269,67 @@ public void testMergeRepositories() throws Exception { assertEquals("central", repositories.get(2).getId()); // default } + /** + * Repositories contributed by a model resolved from a repository are merged recessively: + * the repositories already held by the request or session -- e.g. "central" -- keep their + * id and URL. readEffectiveModel() requests replace semantics for every request type, so + * this is exercised directly against {@link DefaultModelBuilder.ModelBuilderSessionState}. + */ + @Test + public void testResolvedPomRepositoryMergedRecessively() throws Exception { + // Trigger mainSession creation with a regular project build. + ModelBuilderRequest request = ModelBuilderRequest.builder() + .session(session) + .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .source(Sources.buildSource(getPom("props-and-profiles"))) + .build(); + ModelBuilder.ModelBuilderSession mbs = builder.newSession(); + mbs.build(request); + DefaultModelBuilder.ModelBuilderSessionState mainState = + ((DefaultModelBuilder.ModelBuilderSessionImpl) mbs).mainSession; + + // A model resolved from a repository (e.g. a dependency's POM) derives its own session + // state the same way DefaultArtifactDescriptorReader.loadPom does. + ModelBuilderRequest dependencyRequest = ModelBuilderRequest.builder() + .session(session) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .repositoryMerging(ModelBuilderRequest.RepositoryMerging.REQUEST_DOMINANT) + .source(Sources.resolvedSource(getPom("props-and-profiles"), "org.example:downloaded:1.0.0")) + .build(); + DefaultModelBuilder.ModelBuilderSessionState state = mainState.derive(dependencyRequest); + + RemoteRepository central = state.getRepositories().stream() + .filter(r -> "central".equals(r.getId())) + .findFirst() + .orElseThrow(); + + // The resolved model declares a repository that reuses the "central" id. + Model model = Model.newBuilder() + .repositories(List.of(Repository.newBuilder() + .id("central") + .url("https://secondary.example/m2") + .build())) + .build(); + + // readEffectiveModel() requests replace semantics for every request type. + state.mergeRepositories(model, true); + + List repositories = state.getRepositories(); + RemoteRepository mergedCentral = repositories.stream() + .filter(r -> "central".equals(r.getId())) + .findFirst() + .orElseThrow(); + assertEquals( + central.getUrl(), + mergedCentral.getUrl(), + "repository declared by a resolved model must be merged recessively, keeping the" + + " existing repository's URL under a shared id"); + assertTrue( + repositories.stream().noneMatch(r -> r.getUrl().contains("secondary.example")), + "repository declared by a resolved model must not enter the resolution repositories" + + " under an id it does not own"); + } + /** * Verifies that when multiple repositories share the same ID (e.g., after mirror injection * maps both "central" and a profile-defined repo to the same mirror ID), their policies are From e2671e91a2dcfb37dfdaa8ba1f7462cb949f7c31 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 17:03:11 +0200 Subject: [PATCH 02/14] Limit property interpolation in resolved dependency POMs Models built while resolving a dependency POM or one of its parents from a repository (ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY and CONSUMER_PARENT) now interpolate their user/system properties only against a small, environment-independent allowlist (java.*, os.*, maven.*, the separator properties, and the CI-friendly revision/changelist/sha1); everything else, including env.* and arbitrary -D properties, is left as a literal unresolved expression. Models belonging to the project being built use the BUILD_* request types and are unaffected. The prior behavior can be restored globally with the new maven.model.dependencyInterpolation.full system/user property. --- .../impl/model/DefaultModelInterpolator.java | 52 +++++++++++++- .../model/DefaultModelInterpolatorTest.java | 67 +++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java index 1932f3fc5515..9b689bd1326b 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java @@ -57,6 +57,14 @@ public class DefaultModelInterpolator implements ModelInterpolator { private static final List PROJECT_PREFIXES_3_1 = Arrays.asList(PREFIX_POM, PREFIX_PROJECT); private static final List PROJECT_PREFIXES_4_0 = Collections.singletonList(PREFIX_PROJECT); + /** + * The name of the property that opts a build back into the previous behavior of + * interpolating models built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} + * or {@link ModelBuilderRequest.RequestType#CONSUMER_PARENT} against the full set of + * session (system, environment and CLI) properties. Disabled by default. + */ + public static final String FULL_EXTERNAL_INTERPOLATION_PROPERTY = "maven.model.dependencyInterpolation.full"; + // MNG-1927, MNG-2124, MNG-3355: // If the build section is present and the project directory is non-null, we should make // sure interpolation of the directories below uses translated paths. @@ -202,8 +210,19 @@ String doCallback( return new MavenBuildTimestamp(request.getSession().getStartTime(), model.getProperties()) .formattedTimestamp(); } + // Models built at ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY are the models + // Maven builds while resolving a dependency POM from a repository, not the operator's + // own project (see DefaultModelBuilder#resolveAndReadParentExternally and + // DefaultArtifactDescriptorReader for the equivalent path used before this builder is + // invoked). Such models interpolate their user/system properties only against a small, + // environment-independent allowlist; everything else, including environment variables + // and arbitrary -D properties, is left as a literal unresolved expression. + boolean restricted = restrictExternalModelInterpolation(request); + // user properties - String value = request.getUserProperties().get(expression); + String value = (!restricted || isSafeExternalExpression(expression)) + ? request.getUserProperties().get(expression) + : null; // model properties (check before prefixed model reflection to avoid recursion) if (value == null) { value = model.getProperties().get(expression); @@ -221,11 +240,11 @@ String doCallback( } } // system properties - if (value == null) { + if (value == null && (!restricted || isSafeExternalExpression(expression))) { value = request.getSystemProperties().get(expression); } // environment variables - if (value == null) { + if (value == null && !restricted) { value = request.getSystemProperties().get("env." + expression); } // un-prefixed model reflection @@ -235,6 +254,33 @@ String doCallback( return value; } + private static boolean restrictExternalModelInterpolation(ModelBuilderRequest request) { + ModelBuilderRequest.RequestType type = request.getRequestType(); + boolean externalModel = type == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY + || type == ModelBuilderRequest.RequestType.CONSUMER_PARENT; + return externalModel + && !Boolean.parseBoolean(request.getSystemProperties().get(FULL_EXTERNAL_INTERPOLATION_PROPERTY)) + && !Boolean.parseBoolean(request.getUserProperties().get(FULL_EXTERNAL_INTERPOLATION_PROPERTY)); + } + + /** + * Expressions that models built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} + * or {@link ModelBuilderRequest.RequestType#CONSUMER_PARENT} may still resolve from the + * session properties: JVM- and Maven-defined properties, plus the CI-friendly version + * properties (MNG-5895). All other expressions are left literal. + */ + private static boolean isSafeExternalExpression(String expression) { + return expression.startsWith("java.") + || expression.startsWith("os.") + || expression.startsWith("maven.") + || "file.separator".equals(expression) + || "path.separator".equals(expression) + || "line.separator".equals(expression) + || "revision".equals(expression) + || "changelist".equals(expression) + || "sha1".equals(expression); + } + String projectProperty(Model model, Path projectDir, String subExpr, boolean prefixed) { if (projectDir != null) { if (subExpr.equals("basedir")) { diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java index 065bc79ecd5e..ce9f63476f74 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java @@ -443,6 +443,73 @@ public void testEnvars() throws Exception { assertEquals("/path/to/home", out.getProperties().get("outputDirectory")); } + @Test + public void testDependencyModelInterpolationUsesRestrictedPropertySet() throws Exception { + context.put("env.HOME", "/path/to/home"); + context.put("some.property", "other-value"); + context.put("java.version", "21"); + + Map modelProperties = new HashMap<>(); + modelProperties.put("envDir", "${env.HOME}"); + modelProperties.put("propDir", "${some.property}"); + modelProperties.put("jdk", "${java.version}"); + + Model model = Model.newBuilder().properties(modelProperties).build(); + + final SimpleProblemCollector collector = new SimpleProblemCollector(); + ModelBuilderRequest request = createModelBuildingRequest(context) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .build(); + Model out = interpolator.interpolateModel(model, Paths.get("."), request, collector); + assertProblemFree(collector); + + // A model built while resolving a dependency POM from a repository does not interpolate + // environment variables or arbitrary system/user properties... + assertEquals("${env.HOME}", out.getProperties().get("envDir")); + assertEquals("${some.property}", out.getProperties().get("propDir")); + // ...while JVM-defined and other well-known expressions keep resolving. + assertEquals("21", out.getProperties().get("jdk")); + } + + @Test + public void testParentModelInterpolationUsesRestrictedPropertySet() throws Exception { + context.put("env.HOME", "/path/to/home"); + + Map modelProperties = new HashMap<>(); + modelProperties.put("envDir", "${env.HOME}"); + + Model model = Model.newBuilder().properties(modelProperties).build(); + + final SimpleProblemCollector collector = new SimpleProblemCollector(); + ModelBuilderRequest request = createModelBuildingRequest(context) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_PARENT) + .build(); + Model out = interpolator.interpolateModel(model, Paths.get("."), request, collector); + assertProblemFree(collector); + + assertEquals("${env.HOME}", out.getProperties().get("envDir")); + } + + @Test + public void testFullInterpolationOptOutRestoresPreviousBehaviorForResolvedDependencyModel() throws Exception { + context.put("env.HOME", "/path/to/home"); + context.put(DefaultModelInterpolator.FULL_EXTERNAL_INTERPOLATION_PROPERTY, "true"); + + Map modelProperties = new HashMap<>(); + modelProperties.put("envDir", "${env.HOME}"); + + Model model = Model.newBuilder().properties(modelProperties).build(); + + final SimpleProblemCollector collector = new SimpleProblemCollector(); + ModelBuilderRequest request = createModelBuildingRequest(context) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .build(); + Model out = interpolator.interpolateModel(model, Paths.get("."), request, collector); + assertProblemFree(collector); + + assertEquals("/path/to/home", out.getProperties().get("envDir")); + } + @Test public void envarExpressionThatEvaluatesToNullReturnsTheLiteralString() throws Exception { From 7fd9e2616cbf22aa3acf668930268a54beb72a98 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 17:28:16 +0200 Subject: [PATCH 03/14] Limit property interpolation in resolved dependency POMs (compat) Extends the previous restriction to the deprecated compat model-builder interpolator (AbstractStringBasedModelInterpolator), which independently builds its own value-source list. Models built at ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL are the models Maven builds while resolving dependency, parent and BOM-import POMs from a repository; such models now interpolate their user/system properties only against the same allowlist as the modern builder. Operator project builds use a higher validation level and are unaffected. Same maven.model.dependencyInterpolation.full opt-out applies here too. --- .../AbstractStringBasedModelInterpolator.java | 70 ++++++++++++++++--- .../AbstractModelInterpolatorTest.java | 55 +++++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java index 13b81619a531..528f3b262339 100644 --- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java +++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java @@ -50,6 +50,15 @@ */ @Deprecated(since = "4.0.0") public abstract class AbstractStringBasedModelInterpolator implements ModelInterpolator { + + /** + * The name of the property that opts a build back into the previous behavior of + * interpolating models built at {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} + * against the full set of session (system, environment and CLI) properties. Disabled + * by default. + */ + public static final String FULL_EXTERNAL_INTERPOLATION_PROPERTY = "maven.model.dependencyInterpolation.full"; + private static final List PROJECT_PREFIXES = Arrays.asList("pom.", "project."); private static final Collection TRANSLATED_PATH_EXPRESSIONS; @@ -156,25 +165,70 @@ public Object getValue(String expression) { valueSources.add(modelValueSource1); - valueSources.add(new MapBasedValueSource(config.getUserProperties())); + // Models built at VALIDATION_LEVEL_MINIMAL are the models Maven builds while resolving + // dependency, parent and BOM-import POMs from a repository, not the operator's own + // project. Such models interpolate only against their own properties and a small set + // of environment-independent expressions; everything else in the user/system property + // space stays uninterpolated. Operator project builds use a higher validation level and + // keep the full set of value sources, unchanged from previous behavior. + boolean restricted = restrictExternalModelInterpolation(config); + + ValueSource userPropertiesValueSource = new MapBasedValueSource(config.getUserProperties()); + valueSources.add(restricted ? restrictToSafeExpressions(userPropertiesValueSource) : userPropertiesValueSource); // Overwrite existing values in model properties. Otherwise, it's not possible // to define them via command line e.g.: mvn -Drevision=6.5.7 ... versionProcessor.overwriteModelProperties(modelProperties, config); valueSources.add(new MapBasedValueSource(modelProperties)); - valueSources.add(new MapBasedValueSource(config.getSystemProperties())); + ValueSource systemPropertiesValueSource = new MapBasedValueSource(config.getSystemProperties()); + valueSources.add( + restricted ? restrictToSafeExpressions(systemPropertiesValueSource) : systemPropertiesValueSource); + + if (!restricted) { + valueSources.add(new AbstractValueSource(false) { + @Override + public Object getValue(String expression) { + return config.getSystemProperties().getProperty("env." + expression); + } + }); + } + + valueSources.add(modelValueSource2); - valueSources.add(new AbstractValueSource(false) { + return valueSources; + } + + private static boolean restrictExternalModelInterpolation(ModelBuildingRequest config) { + return config.getValidationLevel() < ModelBuildingRequest.VALIDATION_LEVEL_MAVEN_2_0 + && !Boolean.parseBoolean(config.getSystemProperties().getProperty(FULL_EXTERNAL_INTERPOLATION_PROPERTY)) + && !Boolean.parseBoolean(config.getUserProperties().getProperty(FULL_EXTERNAL_INTERPOLATION_PROPERTY)); + } + + private static ValueSource restrictToSafeExpressions(ValueSource source) { + return new AbstractValueSource(false) { @Override public Object getValue(String expression) { - return config.getSystemProperties().getProperty("env." + expression); + return isSafeExternalExpression(expression) ? source.getValue(expression) : null; } - }); - - valueSources.add(modelValueSource2); + }; + } - return valueSources; + /** + * Expressions that models built at {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} + * may still resolve from the session properties: JVM- and Maven-defined properties, plus + * the CI-friendly version properties (MNG-5895). All other expressions are left literal. + */ + private static boolean isSafeExternalExpression(String expression) { + return expression.startsWith("java.") + || expression.startsWith("os.") + || expression.startsWith("maven.") + || "file.separator".equals(expression) + || "path.separator".equals(expression) + || "line.separator".equals(expression) + || "revision".equals(expression) + || "changelist".equals(expression) + || "sha1".equals(expression); } protected List createPostProcessors( diff --git a/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java b/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java index becdafdf6c60..688cce9b0ec8 100644 --- a/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java +++ b/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java @@ -319,6 +319,61 @@ public void testEnvars() throws Exception { assertEquals("/path/to/home", out.getProperties().get("outputDirectory")); } + @Test + public void testMinimalValidationInterpolationUsesRestrictedPropertySet() throws Exception { + context.put("env.HOME", "/path/to/home"); + context.put("some.property", "other-value"); + context.put("java.version", "21"); + + Map modelProperties = new HashMap<>(); + modelProperties.put("envDir", "${env.HOME}"); + modelProperties.put("propDir", "${some.property}"); + modelProperties.put("jdk", "${java.version}"); + + Model model = new Model(org.apache.maven.api.model.Model.newBuilder() + .properties(modelProperties) + .build()); + + ModelInterpolator interpolator = createInterpolator(); + + final SimpleProblemCollector collector = new SimpleProblemCollector(); + ModelBuildingRequest config = createModelBuildingRequest(context); + config.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); + Model out = interpolator.interpolateModel(model, new File("."), config, collector); + assertProblemFree(collector); + + // At minimal validation level (the level used for models built while resolving + // dependency, parent and BOM-import POMs) env and arbitrary system/user properties + // stay literal... + assertEquals("${env.HOME}", out.getProperties().get("envDir")); + assertEquals("${some.property}", out.getProperties().get("propDir")); + // ...while JVM-defined and other well-known expressions keep resolving. + assertEquals("21", out.getProperties().get("jdk")); + } + + @Test + public void testFullInterpolationOptOutRestoresPreviousBehaviorAtMinimalValidationLevel() throws Exception { + context.put("env.HOME", "/path/to/home"); + context.put(AbstractStringBasedModelInterpolator.FULL_EXTERNAL_INTERPOLATION_PROPERTY, "true"); + + Map modelProperties = new HashMap<>(); + modelProperties.put("envDir", "${env.HOME}"); + + Model model = new Model(org.apache.maven.api.model.Model.newBuilder() + .properties(modelProperties) + .build()); + + ModelInterpolator interpolator = createInterpolator(); + + final SimpleProblemCollector collector = new SimpleProblemCollector(); + ModelBuildingRequest config = createModelBuildingRequest(context); + config.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); + Model out = interpolator.interpolateModel(model, new File("."), config, collector); + assertProblemFree(collector); + + assertEquals("/path/to/home", out.getProperties().get("envDir")); + } + @Test public void envarExpressionThatEvaluatesToNullReturnsTheLiteralString() throws Exception { From ef082eeb3f1ec0588c08b6989655d7e371e75f55 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 17:39:52 +0200 Subject: [PATCH 04/14] Skip file, property, and condition profile activation for resolved dependency POMs Models built to resolve a dependency (ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) are read from a repository, not authored by the project being built. For such models, file, property, and condition activation conditions are no longer evaluated, and profiles that do still activate (JDK version, operating system, activeByDefault) no longer contribute repositories or plugin repositories. Profiles supplied via settings or the command line, and models belonging to the project's own parent chain, are unaffected. --- .../maven/impl/model/DefaultModelBuilder.java | 26 ++++++- .../impl/model/DefaultModelBuilderTest.java | 50 +++++++++++++ .../factory/resolved-model-with-profiles.xml | 71 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml 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 6b52574c409f..97de2d366377 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 @@ -1642,12 +1642,36 @@ private void addActivePomProfiles(String modelId, List activePomProfile private List getActiveProfiles( Collection interpolatedProfiles, DefaultProfileActivationContext profileActivationContext) { if (isBuildRequestWithActivation()) { - return profileSelector.getActiveProfiles(interpolatedProfiles, profileActivationContext, this); + Collection eligibleProfiles = interpolatedProfiles; + if (request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) { + // A dependency POM resolved from a repository evaluates only + // platform-derived activation (JDK version, operating system, + // activeByDefault); its profiles contribute no repositories. + eligibleProfiles = interpolatedProfiles.stream() + .filter(profile -> !hasFileOrPropertyOrConditionActivation(profile)) + .map(profile -> profile.withRepositories(List.of()).withPluginRepositories(List.of())) + .toList(); + } + return profileSelector.getActiveProfiles(eligibleProfiles, profileActivationContext, this); } else { return List.of(); } } + /** + * 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}. + */ + private static boolean hasFileOrPropertyOrConditionActivation(Profile profile) { + Activation activation = profile.getActivation(); + return activation != null + && (activation.getFile() != null + || activation.getProperty() != null + || (activation.getCondition() != null + && !activation.getCondition().isBlank())); + } + Model readFileModel() throws ModelBuilderException { return readFileModel(new HashSet<>()); } 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 8eb73a6ad16f..1a00774fae22 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 @@ -126,6 +126,56 @@ public void testPropertiesAndProfiles() { assertEquals("21", result.getEffectiveModel().getProperties().get("maven.compiler.release")); } + /** + * Models built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} come from a + * dependency POM resolved from a repository. Their file, property, and condition activators + * are not evaluated, and their profiles contribute no repositories. A project build, at + * {@link ModelBuilderRequest.RequestType#BUILD_PROJECT}, still evaluates every activator. + * Platform-derived activation (JDK version, operating system, activeByDefault) is unaffected + * at either level. + */ + private ModelBuilderRequest.ModelBuilderRequestBuilder resolvedProfilesRequest( + ModelBuilderRequest.RequestType requestType) { + Map 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("some.gating.property", "true"); + systemProperties.put("some.condition.property", "true"); + return ModelBuilderRequest.builder() + .session(session) + .requestType(requestType) + .systemProperties(systemProperties) + .source(Sources.buildSource(getPom("resolved-model-with-profiles"))); + } + + @Test + public void testProjectBuildEvaluatesAllActivators() { + ModelBuilderRequest request = resolvedProfilesRequest(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .build(); + Model model = builder.newSession().build(request).getEffectiveModel(); + + assertEquals("activated", model.getProperties().get("profile.file")); + assertEquals("activated", model.getProperties().get("profile.property")); + assertEquals("activated", model.getProperties().get("profile.condition")); + assertEquals("activated", model.getProperties().get("profile.jdk")); + assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId()))); + } + + @Test + public void testDependencyModelActivatesOnlyEnvironmentIndependentProfiles() { + ModelBuilderRequest request = resolvedProfilesRequest(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .build(); + Model model = builder.newSession().build(request).getEffectiveModel(); + + assertNull(model.getProperties().get("profile.file")); + assertNull(model.getProperties().get("profile.property")); + assertNull(model.getProperties().get("profile.condition")); + assertEquals("activated", model.getProperties().get("profile.jdk")); + assertTrue(model.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); + } + @Test void testMappedSourcesSupportsConcurrentUpdates() throws Exception { ModelBuilderRequest request = ModelBuilderRequest.builder() 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 new file mode 100644 index 000000000000..46a923eba6de --- /dev/null +++ b/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml @@ -0,0 +1,71 @@ + + + + org.apache.maven.tests + resolved-model-with-profiles + 1.0-SNAPSHOT + pom + + + file-condition + + + ${some.dir} + + + + activated + + + + property-condition + + + some.gating.property + + + + activated + + + + condition-condition + + ${some.condition.property} == 'true' + + + activated + + + + jdk-condition + + [1,) + + + activated + + + + profile-repo + https://repo.example.test/profile + + + + + From ebabcd4e9ce9f53a6eb9cf59f06a407d9d3d8b0d Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 17:44:01 +0200 Subject: [PATCH 05/14] Skip file and property profile activation for resolved dependency POMs (compat) Extends the previous restriction to the deprecated compat model-builder (DefaultModelBuilder), which independently performs its own profile selection and injection. Models built at ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL are the models Maven builds while resolving dependency, parent and BOM-import POMs from a repository. For such models, file and property activation conditions are no longer evaluated, and profiles that do still activate (JDK version, operating system, activeByDefault) no longer contribute repositories or plugin repositories. Profiles supplied via settings or the command line are unaffected. --- .../model/building/DefaultModelBuilder.java | 55 +++++++- .../ExternalModelProfileActivationTest.java | 123 ++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java 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 97a7b8b3c935..fbd1d692906f 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 @@ -290,6 +290,11 @@ protected ModelBuildingResult build(ModelBuildingRequest request, Collection parentIds = new LinkedHashSet<>(); List lineage = new ArrayList<>(); + // Models built for dependency resolution (a dependency POM, one of its parents, or an + // imported BOM) are read at ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL rather than + // the default STRICT level used for the project being built. + boolean externalModel = isExternalModelBuildingRequest(request); + for (ModelData currentData = resultData; currentData != null; ) { lineage.add(currentData); @@ -309,8 +314,10 @@ protected ModelBuildingResult build(ModelBuildingRequest request, Collection interpolatedProfiles = getInterpolatedProfiles(rawModel, profileActivationContext, problems); tmpModel.setProfiles(interpolatedProfiles); - List activePomProfiles = - profileSelector.getActiveProfiles(tmpModel.getProfiles(), profileActivationContext, problems); + List activePomProfiles = profileSelector.getActiveProfiles( + externalModel ? withoutFileAndPropertyActivation(interpolatedProfiles) : tmpModel.getProfiles(), + profileActivationContext, + problems); List rawProfiles = new ArrayList<>(); for (Profile activePomProfile : activePomProfiles) { @@ -320,7 +327,11 @@ protected ModelBuildingResult build(ModelBuildingRequest request, Collection mutator) { return interpolatedActivations; } + /** + * Determines whether the given request builds a model for dependency resolution, i.e. a POM + * read from a remote repository (a dependency POM, one of its parents or an imported BOM) + * rather than a POM belonging to the project being built. Such requests are issued with + * {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL}, see for instance + * {@code DefaultArtifactDescriptorReader#loadPom}. + */ + private static boolean isExternalModelBuildingRequest(ModelBuildingRequest request) { + return request.getValidationLevel() < ModelBuildingRequest.VALIDATION_LEVEL_MAVEN_2_0; + } + + /** + * 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. + */ + private static List withoutFileAndPropertyActivation(List profiles) { + List eligible = new ArrayList<>(profiles.size()); + for (Profile profile : profiles) { + Activation activation = profile.getActivation(); + if (activation == null || (activation.getFile() == null && activation.getProperty() == null)) { + eligible.add(profile); + } + } + return eligible; + } + + /** + * Returns a copy of the given profile with its repositories and plugin repositories cleared. + */ + private static Profile withoutRepositories(Profile profile) { + Profile stripped = profile.clone(); + stripped.setRepositories(Collections.emptyList()); + stripped.setPluginRepositories(Collections.emptyList()); + return stripped; + } + @Override public ModelBuildingResult build(ModelBuildingRequest request, ModelBuildingResult result) throws ModelBuildingException { 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 new file mode 100644 index 000000000000..f6163374a2be --- /dev/null +++ b/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java @@ -0,0 +1,123 @@ +/* + * 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.model.building; + +import java.util.Properties; + +import org.apache.maven.model.Model; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +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, and their profiles contribute no repositories. 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. + */ +class ExternalModelProfileActivationTest { + + private static final String POM = "\n" + + " 4.0.0\n" + + " thegroup\n" + + " withprofiles\n" + + " 1\n" + + " pom\n" + + " \n" + + " \n" + + " file-condition\n" + + " \n" + + " \n" + + " ${some.dir}\n" + + " \n" + + " \n" + + " \n" + + " activated\n" + + " \n" + + " \n" + + " \n" + + " property-condition\n" + + " \n" + + " \n" + + " some.gating.property\n" + + " \n" + + " \n" + + " \n" + + " activated\n" + + " \n" + + " \n" + + " \n" + + " jdk-condition\n" + + " \n" + + " [1,)\n" + + " \n" + + " \n" + + " activated\n" + + " \n" + + " \n" + + " \n" + + " profile-repo\n" + + " https://repo.example.test/profile\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + + private Model build(int validationLevel) 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); + + return builder.build(request).getEffectiveModel(); + } + + @Test + void testProjectBuildEvaluatesAllActivators() throws Exception { + Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_STRICT); + + 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()))); + } + + @Test + void testDependencyPomActivatesOnlyEnvironmentIndependentProfiles() throws Exception { + Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); + + assertNull(model.getProperties().get("profile.file")); + assertNull(model.getProperties().get("profile.property")); + assertEquals("activated", model.getProperties().get("profile.jdk")); + assertTrue(model.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); + } +} From 85c5243cfe906241d00717013dc702e0a2f40890 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 18:38:46 +0200 Subject: [PATCH 06/14] Partition the resolved-parent cache by dependency-resolution origin readAsParentModel() caches the parent model it builds keyed only by source and activation record. A dependency's own remote parent and a project's own remote parent can be the exact same POM source, and the two must not share that cache entry: only a request originating from CONSUMER_DEPENDENCY skips file, property, and condition activation and drops repositories, so a cache hit computed under one lineage must never be handed to the other. request.getRequestType() cannot carry that distinction by itself, because a parent lookup always derives a CONSUMER_PARENT request regardless of what kind of session triggered it. A new sticky externalOrigin flag is threaded through derive() instead -- seeded on CONSUMER_DEPENDENCY and OR'd forward through every derivation, surviving as many parent hops as the lineage has -- and readAsParentModel() and the shared getActiveProfiles() gate both key off it. The cache is partitioned into two tags accordingly, so the two lineages never share an entry for the same source. Internal to ModelBuilderSessionState; no public API change. --- .../maven/impl/model/DefaultModelBuilder.java | 43 +++- .../impl/model/DefaultModelBuilderTest.java | 204 ++++++++++++++++++ 2 files changed, 241 insertions(+), 6 deletions(-) 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 97de2d366377..b48ef75e1fa1 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 @@ -139,6 +139,7 @@ public class DefaultModelBuilder implements ModelBuilder { private static final String FILE = "file"; private static final String IMPORT = "import"; private static final String PARENT = "parent"; + private static final String PARENT_EXTERNAL = "parent-external"; private static final String MODEL = "model"; private final Logger logger = LoggerFactory.getLogger(getClass()); @@ -290,6 +291,14 @@ List getExternalRepositories() { // Contains both GAV coordinates (groupId:artifactId:version) and file paths final Set parentChain; + // Sticky across derive(): true for a session that is itself resolving a dependency + // (ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY), or that was derived, directly + // or transitively, from such a session -- for instance a dependency's own parent POM. + // Kept separate from request.getRequestType() because a parent lookup always derives a + // CONSUMER_PARENT request regardless of what kind of session triggered it, which would + // otherwise lose the distinction this flag preserves. + final boolean externalOrigin; + ModelBuilderSessionState(ModelBuilderRequest request) { this( request.getSession(), @@ -300,7 +309,8 @@ List getExternalRepositories() { List.of(), repos(request), repos(request), - new LinkedHashSet<>()); + new LinkedHashSet<>(), + request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY); } static List repos(ModelBuilderRequest request) { @@ -376,7 +386,8 @@ private ModelBuilderSessionState( List pomRepositories, List externalRepositories, List repositories, - Set parentChain) { + Set parentChain, + boolean externalOrigin) { this.session = session; this.request = request; this.result = result; @@ -386,6 +397,7 @@ private ModelBuilderSessionState( this.externalRepositories = externalRepositories; this.repositories = repositories; this.parentChain = parentChain; + this.externalOrigin = externalOrigin; this.result.setSource(this.request.getSource()); } @@ -427,6 +439,8 @@ ModelBuilderSessionState derive(ModelBuilderRequest request, DefaultModelBuilder derivedRepos = repositoryFactory.aggregate(session, pomRepositories, derivedExtRepos, false); } } + boolean derivedExternalOrigin = + externalOrigin || request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY; return new ModelBuilderSessionState( session, request, @@ -436,7 +450,8 @@ ModelBuilderSessionState derive(ModelBuilderRequest request, DefaultModelBuilder pomRepositories, derivedExtRepos, derivedRepos, - new LinkedHashSet<>()); + new LinkedHashSet<>(), + derivedExternalOrigin); } @Override @@ -1643,8 +1658,9 @@ private List getActiveProfiles( Collection interpolatedProfiles, DefaultProfileActivationContext profileActivationContext) { if (isBuildRequestWithActivation()) { Collection eligibleProfiles = interpolatedProfiles; - if (request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) { - // A dependency POM resolved from a repository evaluates only + 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); its profiles contribute no repositories. eligibleProfiles = interpolatedProfiles.stream() @@ -2073,8 +2089,23 @@ private record ParentModelWithProfiles(Model model, List activatedProfi */ Model readAsParentModel(DefaultProfileActivationContext profileActivationContext, Set parentChain) throws ModelBuilderException { + // Partition the cache by externalOrigin so a parent model resolved while building + // the operator's own project never shares an entry with the same source resolved + // while resolving a dependency: the two contexts activate profiles differently (see + // getActiveProfiles below), and the model built for one must not be reused for the + // other, even though both are keyed off the same underlying source. + // + // This partition is a defensive backstop, not the primary guard: cache(source, tag, + // supplier) additionally scopes each entry to the top-level request (see + // getOuterRequest()), which falls back to the request object's own identity once its + // RequestTrace has no further request-typed ancestor. Two independently-built request + // objects therefore land in different buckets regardless of this tag, and never reach + // this collision in practice; the tag matters only when two reads end up sharing a + // request object (as derive() calls from a common ancestor can), which is why it is + // kept even though the getActiveProfiles gate above already decides the correct + // activation for each read on its own. Map parentsPerContext = - cache(request.getSource(), PARENT, ConcurrentHashMap::new); + cache(request.getSource(), externalOrigin ? PARENT_EXTERNAL : PARENT, ConcurrentHashMap::new); for (Map.Entry e : parentsPerContext.entrySet()) { 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 1a00774fae22..6040b43913ce 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 @@ -176,6 +176,210 @@ public void testDependencyModelActivatesOnlyEnvironmentIndependentProfiles() { assertTrue(model.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); } + private Map parentActivationSystemProperties() { + Map 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("some.gating.property", "true"); + systemProperties.put("some.condition.property", "true"); + return systemProperties; + } + + private DefaultProfileActivationContext parentActivationContext(Map systemProperties) { + org.apache.maven.api.services.Lookup lookup = session.getService(org.apache.maven.api.services.Lookup.class); + return new DefaultProfileActivationContext( + lookup.lookup(org.apache.maven.api.services.model.PathTranslator.class), + lookup.lookup(org.apache.maven.api.services.model.RootLocator.class), + lookup.lookup(org.apache.maven.api.services.Interpolator.class), + List.of(), + List.of(), + systemProperties, + Map.of(), + Model.newInstance()); + } + + /** + * readAsParentModel() caches by (source, tag), where tag depends on externalOrigin, through + * a generic per-request cache keyed off the request object itself. To exercise that + * partition directly -- independent of how deep a real build's RequestTrace ancestry happens + * to be -- both reads below derive from the exact same request instance, differing only in + * which parent session state (one already externalOrigin=true, one false) they derive from. + * That guarantees both calls address the same underlying per-request cache bucket, so this + * proves the tag partition itself separates them, not an accidental difference elsewhere. + */ + @Test + public void testResolvedDependencyParentCacheDoesNotShareActivationWithProjectParent() throws Exception { + // Trigger mainSession creation with a regular project build. + ModelBuilderRequest request = ModelBuilderRequest.builder() + .session(session) + .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .source(Sources.buildSource(getPom("props-and-profiles"))) + .build(); + ModelBuilder.ModelBuilderSession mbs = builder.newSession(); + mbs.build(request); + DefaultModelBuilder.ModelBuilderSessionState mainState = + ((DefaultModelBuilder.ModelBuilderSessionImpl) mbs).mainSession; + + Map systemProperties = parentActivationSystemProperties(); + ModelSource parentSource = Sources.buildSource(getPom("resolved-model-with-profiles")); + + // A session already carrying externalOrigin=true, as a dependency's own session would + // after resolving through at least one CONSUMER_DEPENDENCY hop. + DefaultModelBuilder.ModelBuilderSessionState dependencyAncestorState = + mainState.derive(ModelBuilderRequest.builder(mainState.request) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .source(Sources.buildSource(getPom("props-and-profiles"))) + .build()); + assertTrue(dependencyAncestorState.externalOrigin); + + // The exact same request instance is then used to derive a parent-lookup session from + // each ancestor. Sharing one request object guarantees both reads address the same + // per-request cache bucket, regardless of RequestTrace ancestry depth. + ModelBuilderRequest sharedParentRequest = ModelBuilderRequest.builder(mainState.request) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_PARENT) + .source(parentSource) + .systemProperties(systemProperties) + .build(); + + DefaultModelBuilder.ModelBuilderSessionState projectState = mainState.derive(sharedParentRequest); + assertFalse(projectState.externalOrigin, "derived from a BUILD_PROJECT ancestor, must not be external"); + Model projectParentModel = + projectState.readAsParentModel(parentActivationContext(systemProperties), new HashSet<>()); + + assertEquals("activated", projectParentModel.getProperties().get("profile.file")); + assertEquals("activated", projectParentModel.getProperties().get("profile.property")); + assertEquals("activated", projectParentModel.getProperties().get("profile.condition")); + assertEquals("activated", projectParentModel.getProperties().get("profile.jdk")); + assertTrue(projectParentModel.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId()))); + + DefaultModelBuilder.ModelBuilderSessionState dependencyState = + dependencyAncestorState.derive(sharedParentRequest); + assertTrue(dependencyState.externalOrigin, "derived from a CONSUMER_DEPENDENCY ancestor, must stay external"); + Model dependencyParentModel = + dependencyState.readAsParentModel(parentActivationContext(systemProperties), new HashSet<>()); + + assertNull(dependencyParentModel.getProperties().get("profile.file")); + assertNull(dependencyParentModel.getProperties().get("profile.property")); + assertNull(dependencyParentModel.getProperties().get("profile.condition")); + assertEquals("activated", dependencyParentModel.getProperties().get("profile.jdk")); + assertTrue(dependencyParentModel.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); + + // The first (fully activated) result must not have been altered by the second read. + assertEquals("activated", projectParentModel.getProperties().get("profile.file")); + assertTrue(projectParentModel.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId()))); + + // Repeating with a second, fresh shared request -- dependency read first this time -- + // must not bleed the other way either. + ModelBuilderRequest sharedParentRequest2 = ModelBuilderRequest.builder(mainState.request) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_PARENT) + .source(parentSource) + .systemProperties(systemProperties) + .build(); + + DefaultModelBuilder.ModelBuilderSessionState dependencyState2 = + dependencyAncestorState.derive(sharedParentRequest2); + Model dependencyParentModel2 = + dependencyState2.readAsParentModel(parentActivationContext(systemProperties), new HashSet<>()); + assertNull(dependencyParentModel2.getProperties().get("profile.file")); + assertTrue(dependencyParentModel2.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); + + DefaultModelBuilder.ModelBuilderSessionState projectState2 = mainState.derive(sharedParentRequest2); + Model projectParentModel2 = + projectState2.readAsParentModel(parentActivationContext(systemProperties), new HashSet<>()); + assertEquals("activated", projectParentModel2.getProperties().get("profile.file")); + assertTrue(projectParentModel2.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId()))); + } + + /** + * The externalOrigin flag must survive more than one {@code derive()} hop: a dependency's + * own parent (itself read as a CONSUMER_PARENT session, not CONSUMER_DEPENDENCY) must still + * be treated as external, since request.getRequestType() alone cannot carry that distinction + * once a parent lookup has overwritten it. + */ + @Test + public void testExternalOriginPropagatesThroughGrandparentHop() throws Exception { + ModelBuilderRequest request = ModelBuilderRequest.builder() + .session(session) + .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .source(Sources.buildSource(getPom("props-and-profiles"))) + .build(); + ModelBuilder.ModelBuilderSession mbs = builder.newSession(); + mbs.build(request); + DefaultModelBuilder.ModelBuilderSessionState mainState = + ((DefaultModelBuilder.ModelBuilderSessionImpl) mbs).mainSession; + + Map systemProperties = parentActivationSystemProperties(); + ModelSource dependencySource = Sources.buildSource(getPom("props-and-profiles")); + ModelSource grandparentSource = Sources.buildSource(getPom("resolved-model-with-profiles")); + + // Hop 1: a dependency's own POM. externalOrigin becomes true here. + DefaultModelBuilder.ModelBuilderSessionState dependencyState = + mainState.derive(ModelBuilderRequest.builder(mainState.request) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .source(dependencySource) + .systemProperties(systemProperties) + .build()); + assertTrue(dependencyState.externalOrigin); + + // Hop 2: that dependency's own parent. Its own request type is CONSUMER_PARENT, not + // CONSUMER_DEPENDENCY -- externalOrigin must still be true, inherited from hop 1. + DefaultModelBuilder.ModelBuilderSessionState grandparentHopState = + dependencyState.derive(ModelBuilderRequest.builder(dependencyState.request) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_PARENT) + .source(grandparentSource) + .systemProperties(systemProperties) + .build()); + assertTrue( + grandparentHopState.externalOrigin, + "externalOrigin must survive a second derive() hop, not just the first"); + + Model grandparentModel = + grandparentHopState.readAsParentModel(parentActivationContext(systemProperties), new HashSet<>()); + + assertNull(grandparentModel.getProperties().get("profile.file")); + assertNull(grandparentModel.getProperties().get("profile.property")); + assertNull(grandparentModel.getProperties().get("profile.condition")); + assertEquals("activated", grandparentModel.getProperties().get("profile.jdk")); + assertTrue(grandparentModel.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); + } + + /** + * {@code BUILD_CONSUMER} requests already skip all profile activation + * ({@code isBuildRequestWithActivation()} returns false for that type) -- unrelated to, and + * unaffected by, the externalOrigin distinction. Locking that down explicitly since it is + * adjacent code this change reads but does not modify. + */ + @Test + public void testBuildConsumerSkipsAllProfileActivation() throws Exception { + ModelBuilderRequest request = ModelBuilderRequest.builder() + .session(session) + .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .source(Sources.buildSource(getPom("props-and-profiles"))) + .build(); + ModelBuilder.ModelBuilderSession mbs = builder.newSession(); + mbs.build(request); + DefaultModelBuilder.ModelBuilderSessionState mainState = + ((DefaultModelBuilder.ModelBuilderSessionImpl) mbs).mainSession; + + Map systemProperties = parentActivationSystemProperties(); + DefaultModelBuilder.ModelBuilderSessionState buildConsumerState = + mainState.derive(ModelBuilderRequest.builder(mainState.request) + .requestType(ModelBuilderRequest.RequestType.BUILD_CONSUMER) + .source(Sources.buildSource(getPom("resolved-model-with-profiles"))) + .systemProperties(systemProperties) + .build()); + + Model model = buildConsumerState.readAsParentModel(parentActivationContext(systemProperties), new HashSet<>()); + + assertNull(model.getProperties().get("profile.file")); + assertNull(model.getProperties().get("profile.property")); + assertNull(model.getProperties().get("profile.condition")); + assertNull(model.getProperties().get("profile.jdk")); + assertTrue(model.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); + } + @Test void testMappedSourcesSupportsConcurrentUpdates() throws Exception { ModelBuilderRequest request = ModelBuilderRequest.builder() From 647fb0e300bec1e50b6de7304a065c62489bacdf Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 18:35:36 +0200 Subject: [PATCH 07/14] Restrict system scope in dependency management imported from a repository POM A dependencyManagement import (scope=import or type=bom) reads its managed dependencies from whichever POM the coordinates resolve to. system scope and systemPath bind a dependency to a file on the local filesystem, which is only meaningful for projects belonging to the current build. Track, in doLoadDependencyManagement, whether the import POM was resolved from the reactor or from a repository. Managed dependencies declaring system scope or a systemPath in a repository-resolved import are dropped with a warning, and are removed from the cached model so a later import cannot reintroduce them. The previous behaviour remains available, also with a warning, behind the new maven.repository.dependencyManagement.allowSystemScope user property. Imports resolved from the local reactor are unaffected. --- .../java/org/apache/maven/api/Constants.java | 18 +++++ .../maven/impl/model/DefaultModelBuilder.java | 67 ++++++++++++++++++ .../impl/model/DefaultModelBuilderTest.java | 70 +++++++++++++++++++ .../1.0/system-scope-bom-1.0.pom | 18 +++++ .../poms/factory/import-system-scope-bom.xml | 35 ++++++++++ 5 files changed, 208 insertions(+) create mode 100644 impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom create mode 100644 impl/maven-impl/src/test/resources/poms/factory/import-system-scope-bom.xml 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 7ed3befffac1..2222f025cce3 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 @@ -816,5 +816,23 @@ public final class Constants { */ public static final String MAVEN_MODEL_PROCESSOR_REFERENCE_TYPE_PREFIX = "maven.model.processor.referenceType."; + /** + * User property for allowing {@code system} scope and {@code systemPath} in dependency + * management imported from repository-resolved POMs (BOMs). + *
    + *
  • When set to false (default), dependency management imported from a + * repository-resolved POM may not declare {@code system} scope or a {@code systemPath}; + * such entries are dropped with a warning.
  • + *
  • When set to true, such entries are imported as in previous Maven + * versions, with a warning.
  • + *
+ * Dependency management imported from the local reactor is not affected by this property. + * + * @since 4.1.0 + */ + @Config(type = "java.lang.Boolean", defaultValue = "false") + public static final String MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE = + "maven.repository.dependencyManagement.allowSystemScope"; + private Constants() {} } 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 b48ef75e1fa1..522bbef1b77c 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 @@ -2320,11 +2320,13 @@ private Model doLoadDependencyManagement( Collection importIds) { Model importModel; ModelSource importSource; + boolean repositoryResolved = false; try { importSource = resolveReactorModel(groupId, artifactId, version); if (importSource == null) { importSource = modelResolver.resolveModel( request.getSession(), repositories, dependency, new AtomicReference<>()); + repositoryResolved = true; } } catch (ModelBuilderException | ModelResolverException e) { StringBuilder buffer = new StringBuilder(256); @@ -2375,9 +2377,68 @@ private Model doLoadDependencyManagement( importModel = importResult.getEffectiveModel(); + if (repositoryResolved) { + importModel = rejectSystemScopeFromRepositoryImport(importModel, dependency); + } + return importModel; } + /** + * Dependency management imported (as a BOM) from a POM resolved from a repository, rather + * than from the local reactor, may not declare {@code system} scope or a + * {@code systemPath} for a managed dependency: by default, offending entries are dropped + * from the imported management (so a cached import cannot re-introduce them) and a + * warning is emitted, unless the + * {@code maven.repository.dependencyManagement.allowSystemScope} user property is set to + * {@code true}, in which case they are imported as before, with a warning. Dependency + * management imported from the local reactor is not affected. + */ + private Model rejectSystemScopeFromRepositoryImport(Model importModel, Dependency dependency) { + DependencyManagement importMgmt = importModel != null ? importModel.getDependencyManagement() : null; + if (importMgmt == null) { + return importModel; + } + String offending = importMgmt.getDependencies().stream() + .filter(DefaultModelBuilder::usesSystemScope) + .map(Dependency::getManagementKey) + .collect(Collectors.joining(", ")); + if (offending.isEmpty()) { + return importModel; + } + String allow = request.getUserProperties() + .getOrDefault( + Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE, + request.getSystemProperties() + .get(Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE)); + if (Boolean.parseBoolean(allow)) { + add( + Severity.WARNING, + Version.V41, + "The import POM " + ModelProblemUtils.toId(importModel) + + " declares 'system' scope or 'systemPath' for " + offending + + "; importing it because the '" + + Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE + + "' user property is set to 'true'.", + dependency.getLocation("")); + return importModel; + } + add( + Severity.WARNING, + Version.V41, + "The import POM " + ModelProblemUtils.toId(importModel) + + " was resolved from a repository and declares 'system' scope or 'systemPath' for " + + offending + "; these entries are not imported. Remove the 'system' scope from the" + + " imported POM, or set the '" + + Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE + + "' user property to 'true' to import them as before.", + dependency.getLocation("")); + List retained = importMgmt.getDependencies().stream() + .filter(d -> !usesSystemScope(d)) + .collect(Collectors.toList()); + return importModel.withDependencyManagement(importMgmt.withDependencies(retained)); + } + ModelSource resolveReactorModel(String groupId, String artifactId, String version) throws ModelBuilderException { Set sources = mappedSources.get(new GAKey(groupId, artifactId)); @@ -2533,6 +2594,12 @@ static String getVersion(Model model) { return version; } + static boolean usesSystemScope(Dependency dependency) { + return "system".equals(dependency.getScope()) + || (dependency.getSystemPath() != null + && !dependency.getSystemPath().isEmpty()); + } + private DefaultProfileActivationContext getProfileActivationContext(ModelBuilderRequest request, Model model) { return new DefaultProfileActivationContext( pathTranslator, 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 6040b43913ce..25b3c44bad51 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 @@ -47,6 +47,7 @@ import org.apache.maven.api.model.Model; import org.apache.maven.api.model.Profile; import org.apache.maven.api.model.Repository; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.ModelBuilder; import org.apache.maven.api.services.ModelBuilderRequest; import org.apache.maven.api.services.ModelBuilderResult; @@ -1282,6 +1283,75 @@ public void testBomTypeImpliesImportWithoutScope() { assertEquals("0.1", managed.getVersion()); } + /** + * Dependency management imported from a repository-resolved POM does not contribute + * {@code system} scope or a {@code systemPath}; such entries are dropped with a warning. + */ + @Test + public void testSystemScopeIgnoredOutsideProjectDeclaration() throws Exception { + Path basedir = Paths.get(System.getProperty("basedir", "")); + Path remoteRepoPath = basedir.resolve("src/test/remote-repo"); + + // default: the build succeeds, the offending entry is dropped, and a warning is emitted + Path localRepoPath = basedir.resolve("target/local-repo-system-scope-bom-reject"); + Session rejectSession = ApiRunner.createSession( + injector -> injector.bindInstance(DefaultModelBuilderTest.class, this), localRepoPath); + RemoteRepository remoteRepository = rejectSession.createRemoteRepository( + RemoteRepository.CENTRAL_ID, remoteRepoPath.toUri().toString()); + rejectSession = rejectSession.withRemoteRepositories(List.of(remoteRepository)); + ModelBuilder rejectBuilder = rejectSession.getService(ModelBuilder.class); + + ModelBuilderRequest request = ModelBuilderRequest.builder() + .session(rejectSession) + .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .source(Sources.buildSource(getPom("import-system-scope-bom"))) + .build(); + ModelBuilderResult rejectResult = rejectBuilder.newSession().build(request); + Dependency rejected = rejectResult.getEffectiveModel().getDependencyManagement().getDependencies().stream() + .filter(d -> "system-scope-dep".equals(d.getArtifactId())) + .findFirst() + .orElse(null); + assertNull(rejected, "By default the 'system' scope managed entry should not be imported"); + assertTrue( + rejectResult + .getProblemCollector() + .problems() + .anyMatch(p -> p.getSeverity() == BuilderProblem.Severity.WARNING + && p.getMessage().contains("'system' scope or 'systemPath'") + && p.getMessage() + .contains(Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE)), + "Expected a warning about 'system' scope in the repository-imported BOM"); + + // explicit opt-out (fresh session/local repo, so the sanitized import is not served from the cache) + Path allowedLocalRepoPath = basedir.resolve("target/local-repo-system-scope-bom-allow"); + Session allowedSession = ApiRunner.createSession( + injector -> injector.bindInstance(DefaultModelBuilderTest.class, this), allowedLocalRepoPath); + allowedSession = allowedSession.withRemoteRepositories(List.of(allowedSession.createRemoteRepository( + RemoteRepository.CENTRAL_ID, remoteRepoPath.toUri().toString()))); + ModelBuilder allowedBuilder = allowedSession.getService(ModelBuilder.class); + + ModelBuilderRequest allowed = ModelBuilderRequest.builder() + .session(allowedSession) + .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) + .userProperties(Map.of(Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE, "true")) + .source(Sources.buildSource(getPom("import-system-scope-bom"))) + .build(); + ModelBuilderResult result = allowedBuilder.newSession().build(allowed); + Dependency managed = result.getEffectiveModel().getDependencyManagement().getDependencies().stream() + .filter(d -> "system-scope-dep".equals(d.getArtifactId())) + .findFirst() + .orElse(null); + assertNotNull(managed, "With the opt-out property the managed entry should be imported"); + assertEquals("system", managed.getScope()); + assertTrue( + result.getProblemCollector() + .problems() + .anyMatch(p -> p.getSeverity() == BuilderProblem.Severity.WARNING + && p.getMessage() + .contains(Constants.MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE)), + "Opting out should still emit a warning about the imported 'system' scope"); + } + private static DefaultProfileActivationContext.Record recordActiveProfile( List activeIds, String profileId) { DefaultProfileActivationContext recording = diff --git a/impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom b/impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom new file mode 100644 index 000000000000..51d8867aa4f8 --- /dev/null +++ b/impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom @@ -0,0 +1,18 @@ + + 4.0.0 + org.apache.maven.its + system-scope-bom + 1.0 + pom + + + + org.apache.maven.its + system-scope-dep + 1.0 + system + /etc/imaginary-credentials.jar + + + + diff --git a/impl/maven-impl/src/test/resources/poms/factory/import-system-scope-bom.xml b/impl/maven-impl/src/test/resources/poms/factory/import-system-scope-bom.xml new file mode 100644 index 000000000000..616bb010e51f --- /dev/null +++ b/impl/maven-impl/src/test/resources/poms/factory/import-system-scope-bom.xml @@ -0,0 +1,35 @@ + + + + org.apache.maven.tests + import-system-scope-bom + 1.0-SNAPSHOT + jar + + + + + org.apache.maven.its + system-scope-bom + 1.0 + pom + import + + + + From 27e46550df8eaa5f1c715ed25e15a348d64a8179 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 21:41:54 +0200 Subject: [PATCH 08/14] Cover both the default and the opt-out in the model-resolution tests MavenITmng4379 and MavenITmng3586 cover a systemPath supplied through a property or an environment variable in a POM resolved from a repository; MavenITmng4590 covers dependency management imported from one. Those models now use restricted defaults, so each test requests the previous behaviour explicitly, which exercises the opt-out property, and gains a companion test asserting what the default does instead. Verified: mvn verify -Prun-its -Dits.test='MavenITmng4379*,MavenITmng4590*,MavenITmng3586*' -> 7 tests, 0 failures --- .../MavenIT0085TransitiveSystemScopeTest.java | 31 +++++++++++++++++++ ...ng3586SystemScopePluginDependencyTest.java | 31 +++++++++++++++++++ ...eSystemPathInterpolatedWithEnvVarTest.java | 31 +++++++++++++++++++ ...tedPomUsesSystemAndUserPropertiesTest.java | 29 +++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenIT0085TransitiveSystemScopeTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenIT0085TransitiveSystemScopeTest.java index 62d9900f3299..71a8c9376a67 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenIT0085TransitiveSystemScopeTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenIT0085TransitiveSystemScopeTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; public class MavenIT0085TransitiveSystemScopeTest extends AbstractMavenIntegrationTestCase { @@ -48,10 +49,40 @@ public void testit0085() throws Exception { verifier.addCliArgument("--settings"); verifier.addCliArgument("settings.xml"); verifier.addCliArgument("validate"); + verifier.addCliArgument("-Dmaven.model.dependencyInterpolation.full=true"); verifier.execute(); verifier.verifyErrorFreeLog(); Collection lines = verifier.loadLines("target/test.txt"); assertTrue(lines.contains("system.jar"), lines.toString()); } + + /** + * Verify that by default the path of a system-scope dependency declared by a POM resolved from a + * repository is not interpolated from the session properties, so the expression is left as + * written and reported as an invalid path. + * + * @throws Exception in case of failure + */ + @Test + public void testit0085DefaultLeavesPropertyUnresolved() throws Exception { + Path testDir = extractResources("it0085"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.deleteArtifacts("org.apache.maven.its.it0085"); + verifier.getSystemProperties().setProperty("test.home", testDir.toString()); + verifier.filterFile("settings-template.xml", "settings.xml"); + verifier.addCliArgument("--settings"); + verifier.addCliArgument("settings.xml"); + verifier.addCliArgument("validate"); + try { + verifier.execute(); + verifier.verifyErrorFreeLog(); + fail("Build should not succeed"); + } catch (VerificationException e) { + verifier.verifyTextInLog("must specify an absolute path but is ${test.home}/system.jar"); + } + } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3586SystemScopePluginDependencyTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3586SystemScopePluginDependencyTest.java index d1c94cb7eedd..d2f2a12af256 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3586SystemScopePluginDependencyTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng3586SystemScopePluginDependencyTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; /** * This is a test set for MNG-3586. @@ -52,6 +53,7 @@ public void testitFromPlugin() throws Exception { verifier.addCliArgument("--settings"); verifier.addCliArgument("settings.xml"); verifier.addCliArgument("validate"); + verifier.addCliArgument("-Dmaven.model.dependencyInterpolation.full=true"); verifier.execute(); verifier.verifyErrorFreeLog(); @@ -80,4 +82,33 @@ public void testitFromProject() throws Exception { Properties props = verifier.loadProperties("target/pcl.properties"); assertEquals("1", props.getProperty("maven-core-it.properties.count")); } + + /** + * Test that by default a plugin POM resolved from a repository does not interpolate the path of + * its system-scope dependency from the session properties, so the expression is left as written + * and reported as an invalid path. + * + * @throws Exception in case of failure + */ + @Test + public void testitFromPluginDefaultLeavesPropertyUnresolved() throws Exception { + Path testDir = extractResources("mng-3586/test-1"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.deleteArtifacts("org.apache.maven.its.mng3586"); + verifier.getSystemProperties().setProperty("test.home", testDir.toString()); + verifier.filterFile("settings-template.xml", "settings.xml"); + verifier.addCliArgument("--settings"); + verifier.addCliArgument("settings.xml"); + verifier.addCliArgument("validate"); + try { + verifier.execute(); + verifier.verifyErrorFreeLog(); + fail("Build should not succeed"); + } catch (VerificationException e) { + verifier.verifyTextInLog("must specify an absolute path but is ${test.home}/tools.jar"); + } + } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4379TransitiveSystemPathInterpolatedWithEnvVarTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4379TransitiveSystemPathInterpolatedWithEnvVarTest.java index 7835cb31efb0..620a9c26a874 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4379TransitiveSystemPathInterpolatedWithEnvVarTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4379TransitiveSystemPathInterpolatedWithEnvVarTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * This is a test set for MNG-4379. @@ -51,10 +52,40 @@ public void testit() throws Exception { verifier.addCliArgument("-s"); verifier.addCliArgument("settings.xml"); verifier.addCliArguments("validate"); + verifier.addCliArgument("-Dmaven.model.dependencyInterpolation.full=true"); verifier.execute(); verifier.verifyErrorFreeLog(); List classpath = verifier.loadLines("target/classpath.txt"); assertTrue(classpath.contains("pom.xml"), classpath.toString()); } + + /** + * Test that by default the path of a system-scope dependency declared by a POM resolved from a + * repository is not interpolated using environment variables, so the expression is left as + * written and reported as an invalid path. + * + * @throws Exception in case of failure + */ + @Test + public void testitDefaultLeavesEnvironmentVariableUnresolved() throws Exception { + Path testDir = extractResources("mng-4379"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.deleteArtifacts("org.apache.maven.its.mng4379"); + verifier.filterFile("settings-template.xml", "settings.xml"); + verifier.setEnvironmentVariable("MNG_4379_HOME", testDir.toString()); + verifier.addCliArgument("-s"); + verifier.addCliArgument("settings.xml"); + verifier.addCliArguments("validate"); + try { + verifier.execute(); + verifier.verifyErrorFreeLog(); + fail("Build should not succeed"); + } catch (VerificationException e) { + verifier.verifyTextInLog("must specify an absolute path but is ${env.MNG_4379_HOME}/pom.xml"); + } + } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4590ImportedPomUsesSystemAndUserPropertiesTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4590ImportedPomUsesSystemAndUserPropertiesTest.java index bba1bf13875c..c22c0e16a329 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4590ImportedPomUsesSystemAndUserPropertiesTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng4590ImportedPomUsesSystemAndUserPropertiesTest.java @@ -51,6 +51,8 @@ public void testit() throws Exception { verifier.addCliArgument("--settings"); verifier.addCliArgument("settings.xml"); verifier.addCliArgument("validate"); + verifier.addCliArgument("-Dmaven.repository.dependencyManagement.allowSystemScope=true"); + verifier.addCliArgument("-Dmaven.model.dependencyInterpolation.full=true"); verifier.execute(); verifier.verifyErrorFreeLog(); @@ -61,4 +63,31 @@ public void testit() throws Exception { testDir.resolve("pom.xml"), Path.of(props.getProperty("project.dependencyManagement.dependencies.0.systemPath"))); } + + /** + * Verify that by default a POM imported from a repository contributes no managed dependency + * that declares {@code system} scope or a {@code systemPath}. + * + * @throws Exception in case of failure + */ + @Test + public void testitDefaultOmitsSystemScopedManagedDependency() throws Exception { + Path testDir = extractResources("mng-4590"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.deleteArtifacts("org.apache.maven.its.mng4590"); + verifier.filterFile("settings-template.xml", "settings.xml"); + verifier.setEnvironmentVariable("MAVEN_OPTS", "-Dtest.file=pom.xml"); + verifier.addCliArgument("-Dtest.dir=" + testDir.toString()); + verifier.addCliArgument("--settings"); + verifier.addCliArgument("settings.xml"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + Properties props = verifier.loadProperties("target/pom.properties"); + assertEquals("0", props.getProperty("project.dependencyManagement.dependencies")); + } } From 04a4532fc6fd256839e2feb839a61542b4433645 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 22:39:48 +0200 Subject: [PATCH 09/14] Build the imported BOM fixture with a platform-independent path The static fixture pom used a POSIX-only systemPath, which File.isAbsolute() rejects on Windows; model validation then dropped the imported entry before the code under test ran, so the test's assertNull passed for the wrong reason. The test now writes the BOM into a per-test temporary remote repository with a systemPath computed from the current OS, and asserts that an ordinary managed dependency from the same import is present, so the system-scope assertions can no longer pass vacuously. The now-unused static fixture pom is removed. --- .../impl/model/DefaultModelBuilderTest.java | 59 +++++++++++++++++-- .../1.0/system-scope-bom-1.0.pom | 18 ------ 2 files changed, 55 insertions(+), 22 deletions(-) delete mode 100644 impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom 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 25b3c44bad51..523248b6f757 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 @@ -1286,11 +1286,46 @@ public void testBomTypeImpliesImportWithoutScope() { /** * Dependency management imported from a repository-resolved POM does not contribute * {@code system} scope or a {@code systemPath}; such entries are dropped with a warning. + * + *

The imported BOM is written into a temporary remote repository at run time, with a + * {@code systemPath} that is a real absolute path on whichever OS the test runs on + * (a POSIX-only path such as {@code /etc/...} is not absolute on Windows, which would make + * model validation reject the entry before the code under test ever ran). */ @Test - public void testSystemScopeIgnoredOutsideProjectDeclaration() throws Exception { + public void testSystemScopeIgnoredOutsideProjectDeclaration(@TempDir Path tempDir) throws Exception { Path basedir = Paths.get(System.getProperty("basedir", "")); - Path remoteRepoPath = basedir.resolve("src/test/remote-repo"); + Path remoteRepoPath = tempDir.resolve("remote-repo"); + Path bomDir = remoteRepoPath.resolve("org/apache/maven/its/system-scope-bom/1.0"); + Files.createDirectories(bomDir); + + Path systemPathFile = tempDir.resolve("provided-tool.jar"); + Files.createFile(systemPathFile); + + String bomPom = "\n" + "\n" + + " 4.0.0\n" + + " org.apache.maven.its\n" + + " system-scope-bom\n" + + " 1.0\n" + + " pom\n" + + " \n" + + " \n" + + " \n" + + " org.apache.maven.its\n" + + " system-scope-companion\n" + + " 1.0\n" + + " \n" + + " \n" + + " org.apache.maven.its\n" + + " system-scope-dep\n" + + " 1.0\n" + + " system\n" + + " " + + systemPathFile.toAbsolutePath() + "\n" + " \n" + + " \n" + + " \n" + + "\n"; + Files.writeString(bomDir.resolve("system-scope-bom-1.0.pom"), bomPom); // default: the build succeeds, the offending entry is dropped, and a warning is emitted Path localRepoPath = basedir.resolve("target/local-repo-system-scope-bom-reject"); @@ -1307,7 +1342,15 @@ public void testSystemScopeIgnoredOutsideProjectDeclaration() throws Exception { .source(Sources.buildSource(getPom("import-system-scope-bom"))) .build(); ModelBuilderResult rejectResult = rejectBuilder.newSession().build(request); - Dependency rejected = rejectResult.getEffectiveModel().getDependencyManagement().getDependencies().stream() + DependencyManagement rejectManagement = rejectResult.getEffectiveModel().getDependencyManagement(); + assertNotNull( + rejectManagement.getDependencies().stream() + .filter(d -> "system-scope-companion".equals(d.getArtifactId())) + .findFirst() + .orElse(null), + "The import itself must have happened: the ordinary managed entry from the " + + "imported BOM should be present"); + Dependency rejected = rejectManagement.getDependencies().stream() .filter(d -> "system-scope-dep".equals(d.getArtifactId())) .findFirst() .orElse(null); @@ -1337,7 +1380,15 @@ public void testSystemScopeIgnoredOutsideProjectDeclaration() throws Exception { .source(Sources.buildSource(getPom("import-system-scope-bom"))) .build(); ModelBuilderResult result = allowedBuilder.newSession().build(allowed); - Dependency managed = result.getEffectiveModel().getDependencyManagement().getDependencies().stream() + DependencyManagement allowedManagement = result.getEffectiveModel().getDependencyManagement(); + assertNotNull( + allowedManagement.getDependencies().stream() + .filter(d -> "system-scope-companion".equals(d.getArtifactId())) + .findFirst() + .orElse(null), + "The import itself must have happened: the ordinary managed entry from the " + + "imported BOM should be present"); + Dependency managed = allowedManagement.getDependencies().stream() .filter(d -> "system-scope-dep".equals(d.getArtifactId())) .findFirst() .orElse(null); diff --git a/impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom b/impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom deleted file mode 100644 index 51d8867aa4f8..000000000000 --- a/impl/maven-impl/src/test/remote-repo/org/apache/maven/its/system-scope-bom/1.0/system-scope-bom-1.0.pom +++ /dev/null @@ -1,18 +0,0 @@ - - 4.0.0 - org.apache.maven.its - system-scope-bom - 1.0 - pom - - - - org.apache.maven.its - system-scope-dep - 1.0 - system - /etc/imaginary-credentials.jar - - - - From dd8f0caa45e8a4018ced904df9cc72a99d02a49f Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 23:09:53 +0200 Subject: [PATCH 10/14] Classify a POM supplied as a file as a project build DefaultProjectBuilder decided between BUILD_EFFECTIVE and CONSUMER_DEPENDENCY partly on the request's validation level. Validation strictness says how closely a model is checked, not whose model it is, and the 2.x project builder API asks for MAVEN_2_0 validation on a file the caller supplies, so such a build was labelled a consumer dependency. A non-null pomFile already marks a model backed by a file the caller pointed at -- a model resolved from a repository arrives as a resolved source with no pomFile -- so that alone now decides it. Covered by MavenITmng4765LocalPomProjectBuilderTest, which builds a local file through that API and reads back a command-line property. --- .../maven/project/DefaultProjectBuilder.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java index 481950fe8803..923991b734c9 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java @@ -91,7 +91,6 @@ import org.apache.maven.internal.impl.InternalMavenSession; import org.apache.maven.model.building.DefaultModelProblem; import org.apache.maven.model.building.FileModelSource; -import org.apache.maven.model.building.ModelBuildingRequest; import org.apache.maven.model.building.ModelSource2; import org.apache.maven.model.root.RootLocator; import org.apache.maven.plugin.PluginManagerException; @@ -380,12 +379,13 @@ ProjectBuildingResult build(boolean parent, Path pomFile, ModelSource modelSourc && modelSource.getLocation().endsWith("/org/apache/maven/project/standalone.xml"); ModelBuilderRequest.ModelBuilderRequestBuilder builder = getModelBuildingRequest(); - ModelBuilderRequest.RequestType type = reactorMember - || isStandalone - || (pomFile != null - && this.request.isProcessPlugins() - && this.request.getValidationLevel() - == ModelBuildingRequest.VALIDATION_LEVEL_STRICT) + // A non-null pomFile means the model is backed by a file the caller pointed at: + // either an explicit build(File, ...) invocation or an artifact that resolved from + // the reactor. A model resolved from a repository arrives as a resolved source with + // no pomFile, so this distinguishes the two. Validation strictness says how closely + // the model is checked, not whose model it is, so it is not part of the test: the + // 2.x project builder API asks for MAVEN_2_0 validation on the caller's own file. + ModelBuilderRequest.RequestType type = reactorMember || isStandalone || pomFile != null ? ModelBuilderRequest.RequestType.BUILD_EFFECTIVE : (parent ? ModelBuilderRequest.RequestType.CONSUMER_PARENT From 1c289dd191e19e3e557449881a724b9dafb793cc Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Sun, 30 Aug 2026 23:50:06 +0200 Subject: [PATCH 11/14] Revert "Classify a POM supplied as a file as a project build" This reverts commit dd8f0caa45. RequestType is read for validation strictness as well as for provenance, so promoting every file-backed build to BUILD_EFFECTIVE turned lenient builds strict: DefaultMavenProjectBuilderTest.testPartialResultUponBadDependencyDeclaration, MavenProjectTest.testCloneWithDependencyManagement and PomConstructionTest.testDuplicateDependenciesCauseLastDeclarationToBePickedInLenientMode all depend on the previous behaviour. --- .../maven/project/DefaultProjectBuilder.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java index 923991b734c9..481950fe8803 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java @@ -91,6 +91,7 @@ import org.apache.maven.internal.impl.InternalMavenSession; import org.apache.maven.model.building.DefaultModelProblem; import org.apache.maven.model.building.FileModelSource; +import org.apache.maven.model.building.ModelBuildingRequest; import org.apache.maven.model.building.ModelSource2; import org.apache.maven.model.root.RootLocator; import org.apache.maven.plugin.PluginManagerException; @@ -379,13 +380,12 @@ ProjectBuildingResult build(boolean parent, Path pomFile, ModelSource modelSourc && modelSource.getLocation().endsWith("/org/apache/maven/project/standalone.xml"); ModelBuilderRequest.ModelBuilderRequestBuilder builder = getModelBuildingRequest(); - // A non-null pomFile means the model is backed by a file the caller pointed at: - // either an explicit build(File, ...) invocation or an artifact that resolved from - // the reactor. A model resolved from a repository arrives as a resolved source with - // no pomFile, so this distinguishes the two. Validation strictness says how closely - // the model is checked, not whose model it is, so it is not part of the test: the - // 2.x project builder API asks for MAVEN_2_0 validation on the caller's own file. - ModelBuilderRequest.RequestType type = reactorMember || isStandalone || pomFile != null + ModelBuilderRequest.RequestType type = reactorMember + || isStandalone + || (pomFile != null + && this.request.isProcessPlugins() + && this.request.getValidationLevel() + == ModelBuildingRequest.VALIDATION_LEVEL_STRICT) ? ModelBuilderRequest.RequestType.BUILD_EFFECTIVE : (parent ? ModelBuilderRequest.RequestType.CONSUMER_PARENT From 3068b686ada8ce9f2c776f8b9478e82359debc25 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Mon, 31 Aug 2026 00:10:45 +0200 Subject: [PATCH 12/14] Restrict resolved-model interpolation and activation by source kind The restriction on property interpolation and profile activation for a consumer-dependency or consumer-parent model now also requires the model source to be one Maven resolved from a repository (ModelSource.getModelId() non-null). A POM the caller hands to Maven as a file arrives with the same request type but is not repository-resolved, so it keeps full interpolation and activation. Integration-level cover: MavenITmng4765LocalPomProjectBuilderTest. --- .../maven/impl/model/DefaultModelBuilder.java | 17 ++++++++-- .../impl/model/DefaultModelInterpolator.java | 16 ++++++++-- .../impl/model/DefaultModelBuilderTest.java | 31 +++++++++++++++++-- .../model/DefaultModelInterpolatorTest.java | 29 +++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) 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 522bbef1b77c..ef3439f3b961 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 @@ -310,7 +310,7 @@ List getExternalRepositories() { repos(request), repos(request), new LinkedHashSet<>(), - request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY); + isExternalOrigin(request)); } static List repos(ModelBuilderRequest request) { @@ -439,8 +439,7 @@ ModelBuilderSessionState derive(ModelBuilderRequest request, DefaultModelBuilder derivedRepos = repositoryFactory.aggregate(session, pomRepositories, derivedExtRepos, false); } } - boolean derivedExternalOrigin = - externalOrigin || request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY; + boolean derivedExternalOrigin = externalOrigin || isExternalOrigin(request); return new ModelBuilderSessionState( session, request, @@ -2594,6 +2593,18 @@ static String getVersion(Model model) { return version; } + /** + * Whether the model this request builds was resolved from a repository rather than supplied to + * Maven. {@link org.apache.maven.api.services.Sources#resolvedSource} carries the resolved + * model's coordinates and is the only source kind that does; a POM built from a file the caller + * pointed at reports none. + */ + static boolean isExternalOrigin(ModelBuilderRequest request) { + return request.getRequestType() == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY + && request.getSource() != null + && request.getSource().getModelId() != null; + } + static boolean usesSystemScope(Dependency dependency) { return "system".equals(dependency.getScope()) || (dependency.getSystemPath() != null diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java index 9b689bd1326b..8b3fff3813eb 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java @@ -41,6 +41,7 @@ import org.apache.maven.api.services.ModelBuilderRequest; import org.apache.maven.api.services.ModelProblem; import org.apache.maven.api.services.ModelProblemCollector; +import org.apache.maven.api.services.ModelSource; import org.apache.maven.api.services.model.ModelInterpolator; import org.apache.maven.api.services.model.PathTranslator; import org.apache.maven.api.services.model.RootLocator; @@ -256,13 +257,24 @@ String doCallback( private static boolean restrictExternalModelInterpolation(ModelBuilderRequest request) { ModelBuilderRequest.RequestType type = request.getRequestType(); - boolean externalModel = type == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY - || type == ModelBuilderRequest.RequestType.CONSUMER_PARENT; + boolean externalModel = (type == ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY + || type == ModelBuilderRequest.RequestType.CONSUMER_PARENT) + && isRepositoryResolved(request.getSource()); return externalModel && !Boolean.parseBoolean(request.getSystemProperties().get(FULL_EXTERNAL_INTERPOLATION_PROPERTY)) && !Boolean.parseBoolean(request.getUserProperties().get(FULL_EXTERNAL_INTERPOLATION_PROPERTY)); } + /** + * Whether the given source is one Maven resolved from a repository rather than one supplied to + * it. {@link org.apache.maven.api.services.Sources#resolvedSource} carries the resolved model's + * coordinates, and it is the only source kind that does; a POM built from a file the caller + * pointed at reports none. + */ + private static boolean isRepositoryResolved(ModelSource source) { + return source != null && source.getModelId() != null; + } + /** * Expressions that models built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} * or {@link ModelBuilderRequest.RequestType#CONSUMER_PARENT} may still resolve from the 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 523248b6f757..18126cb9fac2 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 @@ -167,6 +167,9 @@ public void testProjectBuildEvaluatesAllActivators() { @Test public void testDependencyModelActivatesOnlyEnvironmentIndependentProfiles() { ModelBuilderRequest request = resolvedProfilesRequest(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .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(); @@ -177,6 +180,25 @@ public void testDependencyModelActivatesOnlyEnvironmentIndependentProfiles() { assertTrue(model.getRepositories().stream().noneMatch(r -> "profile-repo".equals(r.getId()))); } + /** + * 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 + * Maven resolved from a repository -- is not treated as coming from a repository. Every + * activator still runs, exactly as for a project build. + */ + @Test + public void testCallerSuppliedModelActivatesAllProfiles() { + ModelBuilderRequest request = resolvedProfilesRequest(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .build(); + Model model = builder.newSession().build(request).getEffectiveModel(); + + assertEquals("activated", model.getProperties().get("profile.file")); + assertEquals("activated", model.getProperties().get("profile.property")); + assertEquals("activated", model.getProperties().get("profile.condition")); + assertEquals("activated", model.getProperties().get("profile.jdk")); + assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId()))); + } + private Map parentActivationSystemProperties() { Map systemProperties = new HashMap<>(); for (String name : System.getProperties().stringPropertyNames()) { @@ -231,7 +253,8 @@ public void testResolvedDependencyParentCacheDoesNotShareActivationWithProjectPa DefaultModelBuilder.ModelBuilderSessionState dependencyAncestorState = mainState.derive(ModelBuilderRequest.builder(mainState.request) .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) - .source(Sources.buildSource(getPom("props-and-profiles"))) + .source(Sources.resolvedSource( + getPom("props-and-profiles"), "org.apache.maven.test:props-and-profiles:1.0.0")) .build()); assertTrue(dependencyAncestorState.externalOrigin); @@ -312,10 +335,12 @@ public void testExternalOriginPropagatesThroughGrandparentHop() throws Exception ((DefaultModelBuilder.ModelBuilderSessionImpl) mbs).mainSession; Map systemProperties = parentActivationSystemProperties(); - ModelSource dependencySource = Sources.buildSource(getPom("props-and-profiles")); + ModelSource dependencySource = + Sources.resolvedSource(getPom("props-and-profiles"), "org.apache.maven.test:props-and-profiles:1.0.0"); ModelSource grandparentSource = Sources.buildSource(getPom("resolved-model-with-profiles")); - // Hop 1: a dependency's own POM. externalOrigin becomes true here. + // Hop 1: a dependency's own POM, resolved from a repository. externalOrigin becomes + // true here. DefaultModelBuilder.ModelBuilderSessionState dependencyState = mainState.derive(ModelBuilderRequest.builder(mainState.request) .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java index ce9f63476f74..ef5988ac1d51 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java @@ -48,6 +48,7 @@ import org.apache.maven.api.model.Scm; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.ModelBuilderRequest; +import org.apache.maven.api.services.Sources; import org.apache.maven.api.services.model.ModelInterpolator; import org.apache.maven.api.services.model.RootLocator; import org.apache.maven.impl.model.profile.SimpleProblemCollector; @@ -459,6 +460,7 @@ public void testDependencyModelInterpolationUsesRestrictedPropertySet() throws E final SimpleProblemCollector collector = new SimpleProblemCollector(); ModelBuilderRequest request = createModelBuildingRequest(context) .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .source(Sources.resolvedSource(Paths.get("."), "org.apache.maven.test:dependency:1.0.0")) .build(); Model out = interpolator.interpolateModel(model, Paths.get("."), request, collector); assertProblemFree(collector); @@ -483,6 +485,7 @@ public void testParentModelInterpolationUsesRestrictedPropertySet() throws Excep final SimpleProblemCollector collector = new SimpleProblemCollector(); ModelBuilderRequest request = createModelBuildingRequest(context) .requestType(ModelBuilderRequest.RequestType.CONSUMER_PARENT) + .source(Sources.resolvedSource(Paths.get("."), "org.apache.maven.test:parent:1.0.0")) .build(); Model out = interpolator.interpolateModel(model, Paths.get("."), request, collector); assertProblemFree(collector); @@ -490,6 +493,32 @@ public void testParentModelInterpolationUsesRestrictedPropertySet() throws Excep assertEquals("${env.HOME}", out.getProperties().get("envDir")); } + @Test + public void testCallerSuppliedModelInterpolationIsNotRestricted() throws Exception { + context.put("env.HOME", "/path/to/home"); + context.put("some.property", "other-value"); + + Map modelProperties = new HashMap<>(); + modelProperties.put("envDir", "${env.HOME}"); + modelProperties.put("propDir", "${some.property}"); + + Model model = Model.newBuilder().properties(modelProperties).build(); + + final SimpleProblemCollector collector = new SimpleProblemCollector(); + // A POM the caller hands to Maven as a file arrives with the same CONSUMER_DEPENDENCY + // request type as a dependency POM resolved from a repository, but its source is not + // one Maven resolved -- so the restricted property set above does not apply to it. + ModelBuilderRequest request = createModelBuildingRequest(context) + .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY) + .source(Sources.buildSource(Paths.get("."))) + .build(); + Model out = interpolator.interpolateModel(model, Paths.get("."), request, collector); + assertProblemFree(collector); + + assertEquals("/path/to/home", out.getProperties().get("envDir")); + assertEquals("other-value", out.getProperties().get("propDir")); + } + @Test public void testFullInterpolationOptOutRestoresPreviousBehaviorForResolvedDependencyModel() throws Exception { context.put("env.HOME", "/path/to/home"); From 8b786852f99d7e88fc4b2eabb2d6ac549d1fd33d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 10:01:30 +0200 Subject: [PATCH 13/14] Promote dependencyInterpolation.full to Constants with @Config Move the maven.model.dependencyInterpolation.full property from local constants in DefaultModelInterpolator and AbstractStringBasedModelInterpolator to Constants.java with @Config so it appears in Maven's auto-generated configuration documentation alongside its companion maven.repository.dependencyManagement.allowSystemScope. The compat module keeps a private mirror of the value because it does not depend on maven-api-core. Also add a design-intent comment on the replace && isBuildRequest() guard in DefaultModelBuilder.mergeRepositories to clarify that non-build request types intentionally use recessive merging. Co-Authored-By: Claude Opus 4.6 --- .../java/org/apache/maven/api/Constants.java | 17 +++++++++++++++++ .../AbstractStringBasedModelInterpolator.java | 8 +++----- .../maven/impl/model/DefaultModelBuilder.java | 4 ++++ .../impl/model/DefaultModelInterpolator.java | 15 +++++---------- 4 files changed, 29 insertions(+), 15 deletions(-) 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 2222f025cce3..49670e36a6ec 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 @@ -834,5 +834,22 @@ public final class Constants { public static final String MAVEN_REPOSITORY_DEPENDENCY_MANAGEMENT_ALLOW_SYSTEM_SCOPE = "maven.repository.dependencyManagement.allowSystemScope"; + /** + * User property for opting back into the previous behavior of interpolating + * repository-resolved models (dependencies and parents) against the full set of + * session properties (system, environment and CLI). + *

    + *
  • When set to false (default), models resolved from a + * repository are interpolated only against their own {@code }, + * preventing property leaking from the requesting build into transitive POMs.
  • + *
  • When set to true, full interpolation is applied as in + * previous Maven versions.
  • + *
+ * + * @since 4.1.0 + */ + @Config(type = "java.lang.Boolean", defaultValue = "false") + public static final String MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL = "maven.model.dependencyInterpolation.full"; + private Constants() {} } diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java index 528f3b262339..5469c2067054 100644 --- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java +++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/interpolation/AbstractStringBasedModelInterpolator.java @@ -52,12 +52,10 @@ public abstract class AbstractStringBasedModelInterpolator implements ModelInterpolator { /** - * The name of the property that opts a build back into the previous behavior of - * interpolating models built at {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} - * against the full set of session (system, environment and CLI) properties. Disabled - * by default. + * Local mirror of {@code org.apache.maven.api.Constants.MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL}. + * This compat module does not depend on {@code maven-api-core}, so the value is duplicated here. */ - public static final String FULL_EXTERNAL_INTERPOLATION_PROPERTY = "maven.model.dependencyInterpolation.full"; + private static final String FULL_EXTERNAL_INTERPOLATION_PROPERTY = "maven.model.dependencyInterpolation.full"; private static final List PROJECT_PREFIXES = Arrays.asList("pom.", "project."); 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 ef3439f3b961..7a2b5f8bfab2 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 @@ -655,6 +655,10 @@ public void mergeRepositories(Model model, boolean replace) { .toList(); // Repositories contributed by a model resolved from a repository are merged // recessively; repositories supplied by the request or session keep precedence. + // Note: the isBuildRequest() guard means any future non-build RequestType will + // also use recessive merging (the else branch). This is intentional — only a + // build request has a well-defined set of session/request repositories that + // should take precedence; dependency and parent resolution do not. if (replace && isBuildRequest()) { Set ids = repos.stream().map(RemoteRepository::getId).collect(Collectors.toSet()); repositories = repositories.stream() diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java index 8b3fff3813eb..2effe0f66bdf 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelInterpolator.java @@ -31,6 +31,7 @@ import java.util.function.Function; import java.util.function.UnaryOperator; +import org.apache.maven.api.Constants; import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; @@ -58,14 +59,6 @@ public class DefaultModelInterpolator implements ModelInterpolator { private static final List PROJECT_PREFIXES_3_1 = Arrays.asList(PREFIX_POM, PREFIX_PROJECT); private static final List PROJECT_PREFIXES_4_0 = Collections.singletonList(PREFIX_PROJECT); - /** - * The name of the property that opts a build back into the previous behavior of - * interpolating models built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} - * or {@link ModelBuilderRequest.RequestType#CONSUMER_PARENT} against the full set of - * session (system, environment and CLI) properties. Disabled by default. - */ - public static final String FULL_EXTERNAL_INTERPOLATION_PROPERTY = "maven.model.dependencyInterpolation.full"; - // MNG-1927, MNG-2124, MNG-3355: // If the build section is present and the project directory is non-null, we should make // sure interpolation of the directories below uses translated paths. @@ -261,8 +254,10 @@ private static boolean restrictExternalModelInterpolation(ModelBuilderRequest re || type == ModelBuilderRequest.RequestType.CONSUMER_PARENT) && isRepositoryResolved(request.getSource()); return externalModel - && !Boolean.parseBoolean(request.getSystemProperties().get(FULL_EXTERNAL_INTERPOLATION_PROPERTY)) - && !Boolean.parseBoolean(request.getUserProperties().get(FULL_EXTERNAL_INTERPOLATION_PROPERTY)); + && !Boolean.parseBoolean( + request.getSystemProperties().get(Constants.MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL)) + && !Boolean.parseBoolean( + request.getUserProperties().get(Constants.MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL)); } /** From 46b1763518226531960c2f5dbca906f7ca103af6 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 10:56:29 +0200 Subject: [PATCH 14/14] Fix test compilation after constant promotion to Constants Update DefaultModelInterpolatorTest to use Constants.MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL and AbstractModelInterpolatorTest to use the string literal since the compat constant is now private. Co-Authored-By: Claude Opus 4.6 --- .../model/interpolation/AbstractModelInterpolatorTest.java | 2 +- .../apache/maven/impl/model/DefaultModelInterpolatorTest.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java b/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java index 688cce9b0ec8..029223b3368c 100644 --- a/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java +++ b/compat/maven-model-builder/src/test/java/org/apache/maven/model/interpolation/AbstractModelInterpolatorTest.java @@ -354,7 +354,7 @@ public void testMinimalValidationInterpolationUsesRestrictedPropertySet() throws @Test public void testFullInterpolationOptOutRestoresPreviousBehaviorAtMinimalValidationLevel() throws Exception { context.put("env.HOME", "/path/to/home"); - context.put(AbstractStringBasedModelInterpolator.FULL_EXTERNAL_INTERPOLATION_PROPERTY, "true"); + context.put("maven.model.dependencyInterpolation.full", "true"); Map modelProperties = new HashMap<>(); modelProperties.put("envDir", "${env.HOME}"); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java index ef5988ac1d51..4ee3e14c599d 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelInterpolatorTest.java @@ -36,6 +36,7 @@ import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; +import org.apache.maven.api.Constants; import org.apache.maven.api.Session; import org.apache.maven.api.di.Priority; import org.apache.maven.api.di.Provides; @@ -522,7 +523,7 @@ public void testCallerSuppliedModelInterpolationIsNotRestricted() throws Excepti @Test public void testFullInterpolationOptOutRestoresPreviousBehaviorForResolvedDependencyModel() throws Exception { context.put("env.HOME", "/path/to/home"); - context.put(DefaultModelInterpolator.FULL_EXTERNAL_INTERPOLATION_PROPERTY, "true"); + context.put(Constants.MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL, "true"); Map modelProperties = new HashMap<>(); modelProperties.put("envDir", "${env.HOME}");