diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index e4e7f2dc9f..e2035008d4 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -91,6 +91,14 @@ perimeter. Struts is **in-process** with the application; it has no daemon, no listening socket of its own, and no trust boundary against the application code it runs inside. *(documented — it is a framework, not a server.)* +**Second deployment shape — Struts Boot (`struts2-boot-starter`, since 7.5.0).** The +application is a plain `main()` that calls `StrutsBoot.run(...)`; the starter starts an +embedded Tomcat, registers the Struts filter itself, and opens one HTTP listening socket. +Actions come from a Java `ActionRegistry` instead of (or alongside) `struts.xml`. The +request pipeline behind the filter is unchanged, so everything in §4–§9 applies as-is. +What moves is the **container's defaults** (§5, §5a): with Boot they are chosen by the +framework, not by the operator. *(documented — `boot/`, `apps/boot-hello/README.md`.)* + **Caller roles.** - **Untrusted HTTP client** — sends requests (parameters, headers, cookies, @@ -105,6 +113,9 @@ it runs inside. *(documented — it is a framework, not a server.)* section of the security guidance.)* - **Operator** — deploys the WAR, sets `devMode` off, restricts dev-only plugins, configures the container and JVM. **Trusted.** *(documented.)* + With Boot, the operator still owns the JVM, the host, the network perimeter and the + `struts.boot.*` settings (port, bind address, context path); the container defaults + the starter applies are Struts' (§5). **Component families.** @@ -149,7 +160,8 @@ links to them and assigns each a triage disposition (§13): - **Findings that only manifest with a documented-insecure / non-default setting** (`devMode=true`, Config Browser Plugin exposed in production, DMI enabled, or a §5a hardening knob turned off) → `OUT-OF-MODEL: non-default-config`. *(documented.)* -- **The servlet container, JVM, JDK, and OS**, and the application's own +- **The servlet container, JVM, JDK, and OS** — except the embedded-container defaults + the Boot starter itself applies (§5), which are in model — and the application's own authentication, authorization, session management, CSRF token storage, and transport (TLS). Struts is "a pure web framework," not a security framework. *(documented / inferred — §14 Q-env.)* @@ -210,8 +222,21 @@ documented anti-pattern is `OUT-OF-MODEL`. *(documented/inferred — §14 Q-defa Versions table.)* - The operator runs production with `devMode=false` and dev-only diagnostics (Config Browser Plugin) disabled or access-controlled. *(documented.)* -- Struts opens no sockets and makes no outbound connections of its own; any network - egress is the application's. *(inferred — §14 Q-egress.)* +- Struts makes no outbound connections of its own; any network egress is the + application's. In the WAR shape it opens no sockets. The Boot starter opens exactly + one inbound HTTP listener (§2); it makes no outbound connections either. + *(inferred — §14 Q-egress, Q-boot.)* +- **Boot container defaults are in model.** When `struts2-boot-starter` starts Tomcat, + the framework owns these defaults: + - listens on all interfaces, port 8080 (override `struts.boot.address` / + `struts.boot.port`) + - error responses render without exception report or server version + - `DefaultServlet` serves only an empty per-run docBase + - Tomcat's base directory is a fresh owner-only temp directory, deleted on shutdown + - `devMode` is never switched on + + A deviation from these defaults is a Struts finding. Anything the developer adds on + top (TLS, proxies, a different container) is theirs. *(documented — §14 Q-boot.)* ## §5a Build-time and configuration variants — **the central knob set** @@ -245,6 +270,12 @@ reproduced here.** Only the triage-load-bearing facts: - The **FetchMetadata / COOP / COEP** interceptors are in core's `defaultStack` and enabled since 6.0, COEP in report-only mode; an application opts *out*, per action or stack (§8.5). +- **Registry-defined actions (Boot) are never broader than the `struts.xml` + equivalent.** They extend `struts-default`, keep Strict Method Invocation on, are + added to the OGNL allowlist exactly as XML-declared action classes are, and allow + only their registered method — narrower than XML, which also inherits the package's + global allowed methods. A registry action reachable where the XML equivalent would + not be is a finding. *(documented — WW-5791 spec.)* **Insecure-default question (wave 1).** Because the secure posture is the **7.0 default set**, the triage rule needs ratifying: is "a finding that only works with a @@ -386,7 +417,9 @@ Struts' security work.)* permanently disabled in JDK 24; see §5a). *(documented.)* - **Auto-generated error pages do not escape action names** (historical S2-006) — the app must define custom error pages; XSS in the default error page is a documented - hardening item, not a defended property. *(documented.)* + hardening item, not a defended property. *(documented.)* Boot apps have no + `web.xml`, so the starter suppresses Tomcat's exception report and server info itself + (§5); custom error *content* is still the application's. - **Well-known classes (framework):** OGNL/expression injection, multipart/file-upload parsing bugs, and parameter-pollution are the highest-severity recurring classes — but the recent record adds two more that are currently the *more frequent* ones: @@ -464,6 +497,10 @@ authoritative list; §14 Q12.)* - **Dependency-tail CVEs** (a transitive jar, e.g. a logging or XML library) from an SCA scan — triage upstream unless Struts' own code reaches the vulnerable path with untrusted input. +- **"Boot listens on 0.0.0.0."** That's the documented default, matching standalone + Tomcat and Spring Boot. `struts.boot.address` restricts it. → + `OUT-OF-MODEL: non-default-config` only if the operator set a wider exposure; the + default itself is by design. ## §12 Conditions that would change this model @@ -533,6 +570,10 @@ so what remains is ratifying the *wording*, not the substance. - **Q-egress.** Confirm Struts opens no sockets and makes no outbound connections of its own, so any network egress (and the SSRF surface it implies) is the application's. (§5/§7.) +- **Q-boot.** Confirm the Boot starter's embedded-container defaults (§5) are in model + — a framework-shipped container default that leaks or exposes is a Struts finding, + not `OUT-OF-MODEL: environment` — and that the example app `apps/boot-hello` stays + out of scope like the other showcase apps. (§2/§3/§5.) **Wave 2 — mechanism confirmations** diff --git a/apps/boot-hello/README.md b/apps/boot-hello/README.md new file mode 100644 index 0000000000..c0e048a4c3 --- /dev/null +++ b/apps/boot-hello/README.md @@ -0,0 +1,79 @@ +# Struts Boot Hello + +The smallest Struts Boot application: one action, one FreeMarker template, no `web.xml`, no `struts.xml`. + +## Three steps + +1. Depend on `org.apache.struts:struts2-boot-starter`, at the same version as the rest of Struts. Importing + `org.apache.struts:struts2-bom` in `` keeps the versions aligned so the starter needs no + `` of its own. +2. Implement `StrutsBootApplication` and register actions: + + ```java + public class HelloApp implements StrutsBootApplication { + + public static void main(String[] args) { + StrutsBoot.run(HelloApp.class, args); + } + + @Override + public void actions(ActionRegistry actions) { + actions.action("hello", HelloAction.class).success("hello.ftl"); + } + } + ``` + +3. Put the view in `src/main/resources/templates/hello.ftl` and run `main()`. + +Then open http://localhost:8080/hello.action. + +## Running the jar + +``` +mvn package -pl apps/boot-hello -am -DskipAssembly -DskipTests +java -jar apps/boot-hello/target/struts2-boot-hello-.jar --struts.boot.port=9090 +``` + +`` is the Struts version being built, for example `struts2-boot-hello-7.5.0-SNAPSHOT.jar`. The example is +not deployed to Maven repositories; build it from source. + +## Settings + +Settings are read from `struts.properties` on the classpath. A `--key=value` argument overrides the file. + +| Key | Default | Meaning | +|----------------------------|---------|--------------------------------------------| +| `struts.boot.port` | `8080` | HTTP port; `0` picks a free port | +| `struts.boot.context-path` | `""` | Context path the application is served at | +| `struts.boot.address` | all | Interface to listen on, e.g. `127.0.0.1` | + +## Defaults worth knowing + +- Tomcat listens on all interfaces, not only on localhost; set `struts.boot.address` to restrict it. +- Error pages show the status only, without stack traces or the Tomcat version. +- `struts.devMode` stays off. +- Each start uses a fresh temporary base directory (`struts-boot-`) that is deleted on shutdown. +- Views are FreeMarker only; a result path such as `hello.ftl` resolves to `/templates/hello.ftl` on the classpath. + +## Shading and plugins + +The jar is built with `maven-shade-plugin`. Every Struts plugin ships its own `META-INF/struts-plugin.xml`, and shading +keeps only one file with that name, so with two or more plugins on the classpath all but one would be silently lost. +This example uses no plugins. If your application needs them, package it differently instead of shading: a thin jar +with its dependencies in a `lib/` directory (`maven-dependency-plugin` copies them, `maven-jar-plugin` writes the +`Class-Path` manifest entry), or a distribution built with `maven-assembly-plugin`. + +## Logging + +Struts logs through the Log4j 2 API, and the starter ships no logging backend, so warnings and errors from Struts are +not printed. Add one to your application, for example (version managed by `org.apache.logging.log4j:log4j-bom`): + +```xml + + org.apache.logging.log4j + log4j-core + runtime + +``` + +with a `log4j2.xml` on the classpath, or `log4j-slf4j2-impl` to route to an SLF4J backend. diff --git a/apps/boot-hello/pom.xml b/apps/boot-hello/pom.xml new file mode 100644 index 0000000000..772e082054 --- /dev/null +++ b/apps/boot-hello/pom.xml @@ -0,0 +1,96 @@ + + + + 4.0.0 + + org.apache.struts + struts2-apps + 7.5.0-SNAPSHOT + + + struts2-boot-hello + jar + Struts 2 Boot Hello + Struts Boot example application packaged as a runnable jar + + + true + + + + + org.apache.struts + struts2-boot-starter + + + junit + junit + test + + + org.assertj + assertj-core + test + + + + + ${project.artifactId}-${project.version} + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + org.apache.struts2.boot.hello.HelloApp + + true + + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloAction.java b/apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloAction.java new file mode 100644 index 0000000000..6dea4336db --- /dev/null +++ b/apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloAction.java @@ -0,0 +1,30 @@ +/* + * 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.struts2.boot.hello; + +public class HelloAction { + + public String execute() { + return "success"; + } + + public String getMessage() { + return "Hello from Struts Boot"; + } +} diff --git a/apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloApp.java b/apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloApp.java new file mode 100644 index 0000000000..7c47054ae5 --- /dev/null +++ b/apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloApp.java @@ -0,0 +1,35 @@ +/* + * 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.struts2.boot.hello; + +import org.apache.struts2.boot.ActionRegistry; +import org.apache.struts2.boot.StrutsBoot; +import org.apache.struts2.boot.StrutsBootApplication; + +public class HelloApp implements StrutsBootApplication { + + public static void main(String[] args) { + StrutsBoot.run(HelloApp.class, args); + } + + @Override + public void actions(ActionRegistry actions) { + actions.action("hello", HelloAction.class).success("hello.ftl"); + } +} diff --git a/apps/boot-hello/src/main/resources/templates/hello.ftl b/apps/boot-hello/src/main/resources/templates/hello.ftl new file mode 100644 index 0000000000..be6490fc2e --- /dev/null +++ b/apps/boot-hello/src/main/resources/templates/hello.ftl @@ -0,0 +1,21 @@ +<#-- +/* + * 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. + */ +--> +

<@s.property value="message"/>

diff --git a/apps/boot-hello/src/test/java/org/apache/struts2/boot/hello/HelloAppTest.java b/apps/boot-hello/src/test/java/org/apache/struts2/boot/hello/HelloAppTest.java new file mode 100644 index 0000000000..ba9881a2b3 --- /dev/null +++ b/apps/boot-hello/src/test/java/org/apache/struts2/boot/hello/HelloAppTest.java @@ -0,0 +1,45 @@ +/* + * 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.struts2.boot.hello; + +import org.apache.struts2.boot.StrutsBoot; +import org.apache.struts2.boot.StrutsBootServer; +import org.junit.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HelloAppTest { + + @Test + public void testHelloPageRenders() throws Exception { + try (StrutsBootServer server = StrutsBoot.start(HelloApp.class, "--struts.boot.port=0")) { + HttpRequest request = HttpRequest.newBuilder(URI.create(server.baseUrl() + "/hello.action")).GET().build(); + + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("Hello from Struts Boot"); + } + } +} diff --git a/apps/pom.xml b/apps/pom.xml index 9ff4324987..866c4b5ef1 100644 --- a/apps/pom.xml +++ b/apps/pom.xml @@ -33,6 +33,7 @@ showcase rest-showcase + boot-hello diff --git a/bom/pom.xml b/bom/pom.xml index 9d4200471d..ab2c72a91f 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -129,6 +129,11 @@ struts2-testng-plugin ${project.version} + + org.apache.struts + struts2-boot-starter + ${project.version} + org.apache.struts struts2-tiles-plugin diff --git a/boot/pom.xml b/boot/pom.xml new file mode 100644 index 0000000000..76593c0edf --- /dev/null +++ b/boot/pom.xml @@ -0,0 +1,60 @@ + + + + 4.0.0 + + org.apache.struts + struts2-parent + 7.5.0-SNAPSHOT + ../parent/pom.xml + + + struts2-boot-starter + jar + Struts 2 Boot Starter + + + + org.apache.struts + struts2-core + + + org.apache.tomcat.embed + tomcat-embed-core + + + jakarta.servlet.jsp + jakarta.servlet.jsp-api + runtime + + + junit + junit + test + + + org.assertj + assertj-core + test + + + diff --git a/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java new file mode 100644 index 0000000000..3b9c1830fe --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java @@ -0,0 +1,112 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.config.ConfigurationException; +import org.apache.struts2.interceptor.httpmethod.HttpMethod; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class ActionDefinition { + + public record ResultDefinition(Kind kind, String value) { + + public enum Kind { + TEMPLATE, REDIRECT_ACTION + } + } + + private final String name; + private final Class actionClass; + private final Map results = new LinkedHashMap<>(); + private final Set httpMethods = EnumSet.noneOf(HttpMethod.class); + private String methodName = "execute"; + private String interceptorStack; + + ActionDefinition(String name, Class actionClass) { + this.name = name; + this.actionClass = actionClass; + } + + public ActionDefinition method(String methodName) { + this.methodName = methodName; + return this; + } + + public ActionDefinition success(String templatePath) { + return result("success", templatePath); + } + + public ActionDefinition result(String name, String templatePath) { + if (templatePath == null || !templatePath.endsWith(".ftl")) { + throw new ConfigurationException("Action '" + this.name + "' result '" + name + "' uses '" + templatePath + + "': Struts Boot renders FreeMarker templates only, the path must end with .ftl"); + } + results.put(name, new ResultDefinition(ResultDefinition.Kind.TEMPLATE, templatePath)); + return this; + } + + public ActionDefinition redirectAction(String resultName, String actionName) { + if (actionName == null || actionName.isEmpty()) { + throw new ConfigurationException("Action '" + name + "' result '" + resultName + + "' redirects to an action, so the target action name must not be empty"); + } + results.put(resultName, new ResultDefinition(ResultDefinition.Kind.REDIRECT_ACTION, actionName)); + return this; + } + + public ActionDefinition httpMethods(HttpMethod... methods) { + Collections.addAll(httpMethods, methods); + return this; + } + + public ActionDefinition interceptors(String stackName) { + this.interceptorStack = stackName; + return this; + } + + public String name() { + return name; + } + + public Class actionClass() { + return actionClass; + } + + public String methodName() { + return methodName; + } + + public Map results() { + return Collections.unmodifiableMap(results); + } + + public Set httpMethodSet() { + return Collections.unmodifiableSet(httpMethods); + } + + public Optional interceptorStack() { + return Optional.ofNullable(interceptorStack); + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java b/boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java new file mode 100644 index 0000000000..de92065307 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java @@ -0,0 +1,49 @@ +/* + * 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.struts2.boot; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +public final class ActionRegistry { + + public static final String SERVLET_CONTEXT_ATTRIBUTE = ActionRegistry.class.getName(); + + private final Map namespaces = new LinkedHashMap<>(); + + public ActionDefinition action(String name, Class actionClass) { + return namespaceActions("").action(name, actionClass); + } + + public ActionRegistry namespace(String namespace, Consumer body) { + body.accept(namespaceActions(namespace)); + return this; + } + + public Collection namespaces() { + return Collections.unmodifiableCollection(namespaces.values()); + } + + private NamespaceActions namespaceActions(String namespace) { + return namespaces.computeIfAbsent(namespace, NamespaceActions::new); + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/BootSettings.java b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java new file mode 100644 index 0000000000..8accb60d4d --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java @@ -0,0 +1,119 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.config.ConfigurationException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Optional; +import java.util.Properties; + +public final class BootSettings { + + private static final String PREFIX = "struts.boot."; + private static final String PORT_KEY = PREFIX + "port"; + private static final String CONTEXT_PATH_KEY = PREFIX + "context-path"; + private static final String ADDRESS_KEY = PREFIX + "address"; + private static final String ARGUMENT_PREFIX = "--"; + + private final int port; + private final String contextPath; + private final String address; + + private BootSettings(int port, String contextPath, String address) { + this.port = port; + this.contextPath = contextPath; + this.address = address; + } + + public static BootSettings load(String[] args) { + return load(readStrutsProperties(), args); + } + + public static BootSettings load(Properties fileProperties, String[] args) { + Properties merged = new Properties(); + merged.putAll(fileProperties); + for (String arg : args) { + applyArgument(merged, arg); + } + return new BootSettings( + parsePort(merged.getProperty(PORT_KEY, "8080")), + normaliseContextPath(merged.getProperty(CONTEXT_PATH_KEY, "")), + blankToNull(merged.getProperty(ADDRESS_KEY))); + } + + public int port() { + return port; + } + + public String contextPath() { + return contextPath; + } + + public Optional address() { + return Optional.ofNullable(address); + } + + private static void applyArgument(Properties target, String arg) { + if (!arg.startsWith(ARGUMENT_PREFIX + PREFIX)) { + return; + } + int equals = arg.indexOf('='); + if (equals < 0) { + return; + } + target.setProperty(arg.substring(ARGUMENT_PREFIX.length(), equals), arg.substring(equals + 1)); + } + + private static String normaliseContextPath(String value) { + String path = value.trim(); + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (path.isEmpty()) { + return ""; + } + return path.startsWith("/") ? path : "/" + path; + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static int parsePort(String value) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + throw new ConfigurationException("Invalid " + PORT_KEY + ": '" + value + "' is not a number", e); + } + } + + private static Properties readStrutsProperties() { + Properties properties = new Properties(); + try (InputStream in = Thread.currentThread().getContextClassLoader().getResourceAsStream("struts.properties")) { + if (in != null) { + properties.load(in); + } + } catch (IOException e) { + throw new ConfigurationException("Cannot read struts.properties", e); + } + return properties; + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/HttpMethods.java b/boot/src/main/java/org/apache/struts2/boot/HttpMethods.java new file mode 100644 index 0000000000..44199df2b5 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/HttpMethods.java @@ -0,0 +1,35 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.interceptor.httpmethod.HttpMethod; + +public final class HttpMethods { + + public static final HttpMethod GET = HttpMethod.GET; + public static final HttpMethod POST = HttpMethod.POST; + public static final HttpMethod PUT = HttpMethod.PUT; + public static final HttpMethod DELETE = HttpMethod.DELETE; + public static final HttpMethod PATCH = HttpMethod.PATCH; + public static final HttpMethod HEAD = HttpMethod.HEAD; + public static final HttpMethod OPTIONS = HttpMethod.OPTIONS; + + private HttpMethods() { + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java new file mode 100644 index 0000000000..39b418d92f --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java @@ -0,0 +1,74 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.config.ConfigurationException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public final class NamespaceActions { + + private final String namespace; + private final List actions = new ArrayList<>(); + private String interceptorStack; + + NamespaceActions(String namespace) { + this.namespace = namespace; + } + + public ActionDefinition action(String name, Class actionClass) { + if (name == null || name.isEmpty()) { + throw new ConfigurationException("Action name in namespace '" + namespace + "' must not be empty"); + } + if (name.contains("/")) { + throw new ConfigurationException("Action name '" + name + "' contains a slash; declare the path with namespace(...) instead"); + } + if (actionClass == null) { + throw new ConfigurationException("Action '" + name + "' in namespace '" + namespace + "' has no action class"); + } + for (ActionDefinition existing : actions) { + if (existing.name().equals(name)) { + throw new ConfigurationException("Action '" + name + "' is declared twice in namespace '" + namespace + "'"); + } + } + ActionDefinition definition = new ActionDefinition(name, actionClass); + actions.add(definition); + return definition; + } + + public NamespaceActions interceptors(String stackName) { + this.interceptorStack = stackName; + return this; + } + + public String namespace() { + return namespace; + } + + public Optional interceptorStack() { + return Optional.ofNullable(interceptorStack); + } + + public List actions() { + return Collections.unmodifiableList(actions); + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java new file mode 100644 index 0000000000..dcce8a5b87 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java @@ -0,0 +1,180 @@ +/* + * 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.struts2.boot; + +import jakarta.servlet.ServletContext; +import org.apache.struts2.ObjectFactory; +import org.apache.struts2.config.Configuration; +import org.apache.struts2.config.ConfigurationException; +import org.apache.struts2.config.ConfigurationUtil; +import org.apache.struts2.config.ServletContextAwareConfigurationProvider; +import org.apache.struts2.config.entities.ActionConfig; +import org.apache.struts2.config.entities.PackageConfig; +import org.apache.struts2.config.entities.ResultConfig; +import org.apache.struts2.config.providers.InterceptorBuilder; +import org.apache.struts2.inject.ContainerBuilder; +import org.apache.struts2.ognl.ProviderAllowlist; +import org.apache.struts2.util.ClassLoaderUtil; +import org.apache.struts2.util.location.LocatableProperties; + +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public class RegistryConfigurationProvider implements ServletContextAwareConfigurationProvider { + + private static final String PACKAGE_PREFIX = "struts-boot"; + private static final String TEMPLATE_ROOT = "templates/"; + + private ActionRegistry registry; + private Configuration configuration; + private ProviderAllowlist providerAllowlist; + + public RegistryConfigurationProvider() { + } + + RegistryConfigurationProvider(ActionRegistry registry) { + this.registry = registry; + } + + @Override + public void initWithContext(ServletContext servletContext) { + Object attribute = servletContext.getAttribute(ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE); + if (!(attribute instanceof ActionRegistry actionRegistry)) { + throw new ConfigurationException("No ActionRegistry found in the servlet context attribute " + + ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE); + } + this.registry = actionRegistry; + } + + @Override + public void init(Configuration configuration) { + this.configuration = configuration; + } + + @Override + public void register(ContainerBuilder builder, LocatableProperties props) { + // the registry contributes packages only, no beans or constants + } + + @Override + public boolean needsReload() { + return false; + } + + @Override + public void destroy() { + if (providerAllowlist != null) { + providerAllowlist.clearAllowlist(this); + } + } + + @Override + public void loadPackages() { + PackageConfig strutsDefault = configuration.getPackageConfig("struts-default"); + ObjectFactory objectFactory = configuration.getContainer().getInstance(ObjectFactory.class); + String freemarker = strutsDefault.getAllResultTypeConfigs().get("freemarker").getClassName(); + String redirectAction = strutsDefault.getAllResultTypeConfigs().get("redirectAction").getClassName(); + List existing = new ArrayList<>(configuration.getPackageConfigs().values()); + Set> allowlist = new HashSet<>(); + + for (NamespaceActions namespace : registry.namespaces()) { + String packageName = namespace.namespace().isEmpty() ? PACKAGE_PREFIX : PACKAGE_PREFIX + namespace.namespace(); + PackageConfig.Builder pkg = new PackageConfig.Builder(packageName) + .namespace(namespace.namespace()) + .addParent(strutsDefault); + + for (ActionDefinition definition : namespace.actions()) { + assertNoClash(existing, namespace.namespace(), definition.name()); + if (objectFactory.isNoArgConstructorRequired()) { + verifyActionClass(namespace.namespace(), definition); + } + allowlist.addAll(ConfigurationUtil.getAllClassTypes(definition.actionClass())); + pkg.addActionConfig(definition.name(), + actionConfig(packageName, namespace, definition, pkg, objectFactory, freemarker, redirectAction)); + } + configuration.addPackageConfig(packageName, pkg.build()); + } + providerAllowlist = configuration.getContainer().getInstance(ProviderAllowlist.class); + providerAllowlist.registerAllowlist(this, allowlist); + } + + private ActionConfig actionConfig(String packageName, NamespaceActions namespace, ActionDefinition definition, + PackageConfig.Builder pkg, ObjectFactory objectFactory, + String freemarker, String redirectAction) { + ActionConfig.Builder action = new ActionConfig.Builder(packageName, definition.name(), definition.actionClass().getName()) + .methodName(definition.methodName()) + .addAllowedMethod(definition.methodName()); + if (!definition.httpMethodSet().isEmpty()) { + action.addHttpMethods(definition.httpMethodSet()); + } + Optional stack = definition.interceptorStack().or(namespace::interceptorStack); + if (stack.isPresent()) { + action.addInterceptors(InterceptorBuilder.constructInterceptorReference( + pkg, stack.get(), Map.of(), null, objectFactory)); + } + definition.results().forEach((resultName, result) -> + action.addResultConfig(resultConfig(definition, resultName, result, freemarker, redirectAction))); + return action.build(); + } + + private ResultConfig resultConfig(ActionDefinition definition, String resultName, + ActionDefinition.ResultDefinition result, String freemarker, String redirectAction) { + return switch (result.kind()) { + case TEMPLATE -> { + String resource = TEMPLATE_ROOT + result.value(); + if (ClassLoaderUtil.getResource(resource, RegistryConfigurationProvider.class) == null) { + throw new ConfigurationException("Action '" + definition.name() + "' result '" + resultName + + "' refers to the template '" + resource + "' which is not on the classpath"); + } + yield new ResultConfig.Builder(resultName, freemarker).addParam("location", "/" + resource).build(); + } + case REDIRECT_ACTION -> new ResultConfig.Builder(resultName, redirectAction) + .addParam("actionName", result.value()).build(); + }; + } + + private static void verifyActionClass(String namespace, ActionDefinition definition) { + Class actionClass = definition.actionClass(); + String subject = "Action class [" + actionClass.getName() + "] of action '" + definition.name() + + "' in namespace '" + namespace + "'"; + if (!Modifier.isPublic(actionClass.getModifiers())) { + throw new ConfigurationException(subject + " is not public"); + } + try { + actionClass.getConstructor(); + } catch (NoSuchMethodException e) { + throw new ConfigurationException(subject + " does not have a public no-arg constructor", e); + } + } + + private void assertNoClash(List existing, String namespace, String actionName) { + for (PackageConfig other : existing) { + if (!other.isAbstract() && namespace.equals(other.getNamespace()) + && other.getActionConfigs().containsKey(actionName)) { + throw new ConfigurationException("Action '" + actionName + "' in namespace '" + namespace + + "' is declared both in the action registry and in the XML package '" + other.getName() + "'"); + } + } + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBoot.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBoot.java new file mode 100644 index 0000000000..103da42a15 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBoot.java @@ -0,0 +1,50 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.config.ConfigurationException; + +import java.lang.reflect.InvocationTargetException; + +public final class StrutsBoot { + + private StrutsBoot() { + } + + public static void run(Class app, String... args) { + start(app, args).await(); + } + + public static StrutsBootServer start(Class app, String... args) { + ActionRegistry registry = new ActionRegistry(); + instantiate(app).actions(registry); + return StrutsBootServer.start(registry, BootSettings.load(args)); + } + + private static StrutsBootApplication instantiate(Class app) { + try { + return app.getDeclaredConstructor().newInstance(); + } catch (InvocationTargetException e) { + throw new ConfigurationException("Cannot create " + app.getName() + ": its constructor threw", e.getCause()); + } catch (ReflectiveOperationException e) { + throw new ConfigurationException("Cannot create " + app.getName() + + ": it needs a public no-argument constructor", e); + } + } +} diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java new file mode 100644 index 0000000000..8377be29c9 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java @@ -0,0 +1,24 @@ +/* + * 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.struts2.boot; + +public interface StrutsBootApplication { + + void actions(ActionRegistry actions); +} diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java new file mode 100644 index 0000000000..d73b21ed70 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java @@ -0,0 +1,256 @@ +/* + * 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.struts2.boot; + +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleState; +import org.apache.catalina.connector.Connector; +import org.apache.catalina.servlets.DefaultServlet; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.valves.ErrorReportValve; +import org.apache.struts2.config.ConfigurationException; +import org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter; +import org.apache.tomcat.util.descriptor.web.FilterDef; +import org.apache.tomcat.util.descriptor.web.FilterMap; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Comparator; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +public final class StrutsBootServer implements AutoCloseable { + + private static final String FILTER_NAME = "struts"; + + private final Tomcat tomcat; + private final Path baseDir; + private final BootSettings settings; + private final AtomicBoolean closed = new AtomicBoolean(); + private final Thread shutdownHook = new Thread(this::close, "struts-boot-shutdown"); + private Connector connector; + + private StrutsBootServer(Tomcat tomcat, Path baseDir, BootSettings settings) { + this.tomcat = tomcat; + this.baseDir = baseDir; + this.settings = settings; + } + + static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { + CapturingStrutsFilter filter = new CapturingStrutsFilter(); + Tomcat tomcat = new Tomcat(); + Path baseDir = createBaseDir(); + StrutsBootServer server = new StrutsBootServer(tomcat, baseDir, settings); + try { + tomcat.setBaseDir(baseDir.toString()); + hideErrorReportDetails(tomcat); + Path docBase = Files.createDirectory(baseDir.resolve("webapp")); + Context context = tomcat.addContext(settings.contextPath(), docBase.toString()); + context.addServletContainerInitializer( + (classes, servletContext) -> servletContext.setAttribute(ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE, registry), + null); + Tomcat.addServlet(context, "default", new DefaultServlet()); + context.addServletMappingDecoded("/", "default"); + addStrutsFilter(context, filter); + + tomcat.start(); + assertContextStarted(context, filter); + server.openConnector(); + Runtime.getRuntime().addShutdownHook(server.shutdownHook); + } catch (Exception e) { + ConfigurationException failure = e instanceof ConfigurationException configurationException + ? configurationException + : new ConfigurationException("Struts Boot failed to start: " + e.getMessage(), e); + closeAfterFailure(server, failure); + throw failure; + } catch (Error e) { + closeAfterFailure(server, e); + throw e; + } + return server; + } + + public int port() { + return connector.getLocalPort(); + } + + public String baseUrl() { + return "http://" + urlHost() + ":" + port() + settings.contextPath(); + } + + public Path baseDir() { + return baseDir; + } + + public void await() { + tomcat.getServer().await(); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + tomcat.stop(); + tomcat.destroy(); + } catch (Exception e) { + throw new ConfigurationException("Struts Boot failed to stop Tomcat", e); + } finally { + deleteRecursively(baseDir); + removeShutdownHook(); + } + } + + private static void closeAfterFailure(StrutsBootServer server, Throwable failure) { + try { + server.close(); + } catch (RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + private void openConnector() { + Connector created = new Connector(); + created.setPort(settings.port()); + settings.address().ifPresent(address -> { + if (!created.setProperty("address", address)) { + throw new ConfigurationException("Invalid struts.boot.address: '" + address + "'"); + } + }); + connector = created; + tomcat.setConnector(created); + if (created.getState() != LifecycleState.STARTED || created.getLocalPort() < 0) { + throw new ConfigurationException("Struts Boot failed to start: connector state " + + created.getState() + " on port " + settings.port()); + } + } + + private String urlHost() { + return settings.address() + .filter(address -> !address.equals("0.0.0.0") && !address.equals("::")) + .map(address -> address.contains(":") ? "[" + address + "]" : address) + .orElse("127.0.0.1"); + } + + private void removeShutdownHook() { + if (Thread.currentThread() == shutdownHook) { + return; + } + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ignored) { + // the JVM is already shutting down + } + } + + private static void assertContextStarted(Context context, CapturingStrutsFilter filter) { + Throwable initFailure = filter.failure.get(); + if (initFailure != null || context.getState() != LifecycleState.STARTED) { + String reason = initFailure == null ? "context state " + context.getState() : rootMessage(initFailure); + throw new ConfigurationException("Struts Boot failed to start: " + reason, initFailure); + } + } + + private static void hideErrorReportDetails(Tomcat tomcat) { + ErrorReportValve valve = new ErrorReportValve(); + valve.setShowReport(false); + valve.setShowServerInfo(false); + tomcat.getHost().getPipeline().addValve(valve); + } + + private static void addStrutsFilter(Context context, CapturingStrutsFilter filter) { + FilterDef filterDef = new FilterDef(); + filterDef.setFilterName(FILTER_NAME); + filterDef.setFilter(filter); + filterDef.setFilterClass(filter.getClass().getName()); + filterDef.addInitParameter("configProviders", RegistryConfigurationProvider.class.getName()); + context.addFilterDef(filterDef); + FilterMap filterMap = new FilterMap(); + filterMap.setFilterName(FILTER_NAME); + filterMap.addURLPattern("/*"); + context.addFilterMap(filterMap); + } + + private static Path createBaseDir() { + try { + String prefix = "struts-boot-" + UUID.randomUUID(); + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + return Files.createTempDirectory(prefix, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + } + File dir = Files.createTempDirectory(prefix).toFile(); + if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true)) { + Files.delete(dir.toPath()); + throw new ConfigurationException("Cannot restrict the Tomcat base directory to its owner: " + dir); + } + return dir.toPath(); + } catch (IOException e) { + throw new ConfigurationException("Cannot create the Tomcat base directory", e); + } + } + + private static void deleteRecursively(Path dir) { + if (!Files.exists(dir)) { + return; + } + try (Stream paths = Files.walk(dir)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String rootMessage(Throwable throwable) { + Throwable root = throwable; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + return root.getMessage(); + } + + private static final class CapturingStrutsFilter extends StrutsPrepareAndExecuteFilter { + + private final AtomicReference failure = new AtomicReference<>(); + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + try { + super.init(filterConfig); + } catch (Throwable e) { + failure.set(e); + throw e; + } + } + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java new file mode 100644 index 0000000000..5ef5a3ef29 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java @@ -0,0 +1,107 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.boot.ActionDefinition.ResultDefinition; +import org.apache.struts2.boot.ActionDefinition.ResultDefinition.Kind; +import org.apache.struts2.config.ConfigurationException; +import org.apache.struts2.interceptor.httpmethod.HttpMethod; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class ActionRegistryTest { + + @Test + public void testTopLevelActionGoesToEmptyNamespace() { + ActionRegistry r = new ActionRegistry(); + r.action("hello", Object.class).success("hello.ftl"); + assertThat(r.namespaces()).extracting(NamespaceActions::namespace).containsExactly(""); + ActionDefinition a = r.namespaces().iterator().next().actions().get(0); + assertThat(a.methodName()).isEqualTo("execute"); + assertThat(a.results()).containsEntry("success", new ResultDefinition(Kind.TEMPLATE, "hello.ftl")); + } + + @Test + public void testNamespaceDeclaredTwiceMerges() { + ActionRegistry r = new ActionRegistry(); + r.namespace("/admin", n -> n.action("a", Object.class)); + r.namespace("/admin", n -> n.action("b", Object.class)); + assertThat(r.namespaces()).hasSize(1); + assertThat(r.namespaces().iterator().next().actions()).extracting(ActionDefinition::name).containsExactly("a", "b"); + } + + @Test + public void testDuplicateAcrossRepeatedNamespaceThrows() { + ActionRegistry r = new ActionRegistry(); + r.namespace("/admin", n -> n.action("a", Object.class)); + assertThatThrownBy(() -> r.namespace("/admin", n -> n.action("a", Object.class))) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("/admin").hasMessageContaining("a"); + } + + @Test + public void testDuplicateTopLevelThrows() { + ActionRegistry r = new ActionRegistry(); + r.action("hello", Object.class); + assertThatThrownBy(() -> r.action("hello", Object.class)) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("hello"); + } + + @Test + public void testSlashInActionNameThrows() { + ActionRegistry r = new ActionRegistry(); + assertThatThrownBy(() -> r.action("users/save", Object.class)) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("namespace"); + } + + @Test + public void testNonFtlTemplateThrows() { + ActionDefinition hello = new ActionRegistry().action("hello", Object.class); + assertThatThrownBy(() -> hello.success("hello.jsp")) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("FreeMarker"); + } + + @Test + public void testNullActionClassThrows() { + ActionRegistry r = new ActionRegistry(); + assertThatThrownBy(() -> r.action("hello", null)) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("hello").hasMessageContaining("class"); + } + + @Test + public void testRedirectActionWithoutTargetThrows() { + ActionDefinition a = new ActionRegistry().action("save", Object.class); + assertThatThrownBy(() -> a.redirectAction("success", null)) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("save").hasMessageContaining("success"); + assertThatThrownBy(() -> a.redirectAction("success", "")) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("save").hasMessageContaining("success"); + } + + @Test + public void testFluentCallsAreRecorded() { + ActionDefinition a = new ActionRegistry().action("save", Object.class) + .method("save").httpMethods(HttpMethods.POST) + .result("input", "form.ftl").redirectAction("success", "users").interceptors("paramsPrepareParamsStack"); + assertThat(a.methodName()).isEqualTo("save"); + assertThat(a.httpMethodSet()).containsExactly(HttpMethod.POST); + assertThat(a.results()).containsEntry("success", new ResultDefinition(Kind.REDIRECT_ACTION, "users")); + assertThat(a.interceptorStack()).contains("paramsPrepareParamsStack"); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java new file mode 100644 index 0000000000..593be770f9 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java @@ -0,0 +1,122 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.config.ConfigurationException; +import org.junit.Test; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class BootSettingsTest { + + @Test + public void testDefaults() { + BootSettings settings = BootSettings.load(new Properties(), new String[0]); + + assertThat(settings.port()).isEqualTo(8080); + assertThat(settings.contextPath()).isEmpty(); + } + + @Test + public void testFileValue() { + Properties file = new Properties(); + file.setProperty("struts.boot.port", "8081"); + + assertThat(BootSettings.load(file, new String[0]).port()).isEqualTo(8081); + } + + @Test + public void testArgumentBeatsFile() { + Properties file = new Properties(); + file.setProperty("struts.boot.port", "8081"); + + BootSettings settings = BootSettings.load(file, new String[]{"--struts.boot.port=9090"}); + + assertThat(settings.port()).isEqualTo(9090); + } + + @Test + public void testUnknownArgumentsIgnored() { + BootSettings settings = BootSettings.load(new Properties(), new String[]{"--foo=bar", "positional"}); + + assertThat(settings.port()).isEqualTo(8080); + assertThat(settings.contextPath()).isEmpty(); + } + + @Test + public void testContextPathFromArgs() { + BootSettings settings = BootSettings.load(new Properties(), new String[]{"--struts.boot.context-path=/ctx"}); + + assertThat(settings.contextPath()).isEqualTo("/ctx"); + } + + @Test + public void testAddressDefaultsToAllInterfaces() { + assertThat(BootSettings.load(new Properties(), new String[0]).address()).isEmpty(); + } + + @Test + public void testAddressFromFile() { + Properties file = new Properties(); + file.setProperty("struts.boot.address", "127.0.0.1"); + + assertThat(BootSettings.load(file, new String[0]).address()).contains("127.0.0.1"); + } + + @Test + public void testAddressArgumentBeatsFile() { + Properties file = new Properties(); + file.setProperty("struts.boot.address", "0.0.0.0"); + + BootSettings settings = BootSettings.load(file, new String[]{"--struts.boot.address=127.0.0.1"}); + + assertThat(settings.address()).contains("127.0.0.1"); + } + + @Test + public void testBlankAddressMeansAllInterfaces() { + assertThat(BootSettings.load(new Properties(), new String[]{"--struts.boot.address= "}).address()).isEmpty(); + } + + @Test + public void testContextPathNormalised() { + assertThat(contextPath("ctx")).isEqualTo("/ctx"); + assertThat(contextPath("/")).isEmpty(); + assertThat(contextPath("")).isEmpty(); + assertThat(contextPath(" ")).isEmpty(); + assertThat(contextPath("/ctx/")).isEqualTo("/ctx"); + assertThat(contextPath(" /ctx ")).isEqualTo("/ctx"); + assertThat(contextPath("/a/b")).isEqualTo("/a/b"); + } + + private static String contextPath(String value) { + return BootSettings.load(new Properties(), new String[]{"--struts.boot.context-path=" + value}).contextPath(); + } + + @Test + public void testNonNumericPortThrows() { + Properties fileProperties = new Properties(); + String[] args = {"--struts.boot.port=abc"}; + assertThatThrownBy(() -> BootSettings.load(fileProperties, args)) + .isInstanceOf(ConfigurationException.class); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java new file mode 100644 index 0000000000..7a4b2460cf --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java @@ -0,0 +1,265 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.config.Configuration; +import org.apache.struts2.config.ConfigurationException; +import org.apache.struts2.config.ConfigurationManager; +import org.apache.struts2.config.DefaultPropertiesProvider; +import org.apache.struts2.config.StrutsBeanSelectionProvider; +import org.apache.struts2.config.StrutsXmlConfigurationProvider; +import org.apache.struts2.config.entities.ActionConfig; +import org.apache.struts2.config.entities.PackageConfig; +import org.apache.struts2.config.entities.ResultConfig; +import org.apache.struts2.boot.fixture.UserAction; +import org.apache.struts2.inject.Container; +import org.apache.struts2.ognl.ProviderAllowlist; +import org.apache.struts2.ActionContext; +import org.junit.After; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.apache.struts2.boot.HttpMethods.POST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class RegistryConfigurationProviderTest { + + private ConfigurationManager configurationManager; + + @After + public void tearDown() { + if (configurationManager != null) { + configurationManager.destroyConfiguration(); + } + ActionContext.clear(); + } + + private Configuration load(ActionRegistry registry, String... extraXml) { + configurationManager = new ConfigurationManager(Container.DEFAULT_NAME); + configurationManager.addContainerProvider(new DefaultPropertiesProvider()); + configurationManager.addContainerProvider(new StrutsXmlConfigurationProvider("struts-default.xml")); + for (String xml : extraXml) { + configurationManager.addContainerProvider(new StrutsXmlConfigurationProvider(xml)); + } + configurationManager.addContainerProvider(new StrutsBeanSelectionProvider()); + configurationManager.addContainerProvider(new RegistryConfigurationProvider(registry)); + return configurationManager.getConfiguration(); + } + + private ActionRegistry usersRegistry() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("users", UserAction.class).success("admin/users.ftl")); + return registry; + } + + private ActionConfig action(Configuration configuration, String pkg, String name) { + return configuration.getPackageConfig(pkg).getActionConfigs().get(name); + } + + private String resultType(Configuration configuration, String type) { + return configuration.getPackageConfig("struts-default").getAllResultTypeConfigs().get(type).getClassName(); + } + + @Test + public void testPackageExtendsStrutsDefaultWithNamespace() { + Configuration configuration = load(usersRegistry()); + + PackageConfig pkg = configuration.getPackageConfig("struts-boot/admin"); + + assertThat(pkg.getNamespace()).isEqualTo("/admin"); + assertThat(pkg.getParents()).extracting(PackageConfig::getName).containsExactly("struts-default"); + assertThat(pkg.isStrictMethodInvocation()).isTrue(); + } + + @Test + public void testActionClassesAreAllowlisted() { + Configuration configuration = load(usersRegistry()); + + assertThat(configuration.getContainer().getInstance(ProviderAllowlist.class).getProviderAllowlist()) + .contains(UserAction.class); + } + + @Test + public void testRootNamespacePackageName() { + ActionRegistry registry = new ActionRegistry(); + registry.action("hello", UserAction.class).success("hello.ftl"); + + Configuration configuration = load(registry); + + assertThat(configuration.getPackageConfig("struts-boot").getNamespace()).isEmpty(); + } + + @Test + public void testMethodIsAddedToAllowedMethods() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("users", UserAction.class).method("list").success("admin/users.ftl")); + + ActionConfig action = action(load(registry), "struts-boot/admin", "users"); + + assertThat(action.getMethodName()).isEqualTo("list"); + assertThat(action.getAllowedMethods()).contains("list").doesNotContain("delete"); + } + + @Test + public void testTemplateResultIsAbsoluteFreemarker() { + Configuration configuration = load(usersRegistry()); + + ResultConfig result = action(configuration, "struts-boot/admin", "users").getResults().get("success"); + + assertThat(result.getClassName()).isEqualTo(resultType(configuration, "freemarker")); + assertThat(result.getParams()).containsEntry("location", "/templates/admin/users.ftl"); + } + + @Test + public void testRedirectActionResult() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("save", UserAction.class).method("save").redirectAction("success", "users")); + Configuration configuration = load(registry); + + ResultConfig result = action(configuration, "struts-boot/admin", "save").getResults().get("success"); + + assertThat(result.getClassName()).isEqualTo(resultType(configuration, "redirectAction")); + assertThat(result.getParams()).containsEntry("actionName", "users"); + } + + @Test + public void testHttpMethodsCarried() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("save", UserAction.class).method("save").httpMethods(POST).success("admin/users.ftl")); + + ActionConfig action = action(load(registry), "struts-boot/admin", "save"); + + assertThat(action.getHttpMethods()).containsExactly(POST); + } + + @Test + public void testNoStackMeansPackageDefault() { + Configuration configuration = load(usersRegistry()); + + assertThat(action(configuration, "struts-boot/admin", "users").getInterceptors()).isEmpty(); + assertThat(configuration.getRuntimeConfiguration().getActionConfig("/admin", "users").getInterceptors()).isNotEmpty(); + } + + @Test + public void testNamedStackResolved() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("users", UserAction.class).interceptors("basicStack").success("admin/users.ftl")); + + ActionConfig action = action(load(registry), "struts-boot/admin", "users"); + + assertThat(action.getInterceptors()).isNotEmpty(); + } + + @Test + public void testNamespaceStackAppliesToActions() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> { + ns.interceptors("basicStack"); + ns.action("users", UserAction.class).success("admin/users.ftl"); + }); + + assertThat(action(load(registry), "struts-boot/admin", "users").getInterceptors()).isNotEmpty(); + } + + @Test + public void testUnknownStackThrows() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("users", UserAction.class).interceptors("noSuchStack").success("admin/users.ftl")); + + assertThatThrownBy(() -> load(registry)).isInstanceOf(ConfigurationException.class); + } + + @Test + public void testMissingTemplateThrows() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("users", UserAction.class).success("missing.ftl")); + + assertThatThrownBy(() -> load(registry)) + .isInstanceOf(ConfigurationException.class) + .rootCause().hasMessageContaining("templates/missing.ftl"); + } + + @Test + public void testNonPublicActionClassThrows() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("hidden", NonPublicAction.class).success("admin/users.ftl")); + + assertThatThrownBy(() -> load(registry)) + .isInstanceOf(ConfigurationException.class) + .rootCause().hasMessageContaining(NonPublicAction.class.getName()) + .hasMessageContaining("hidden").hasMessageContaining("not public"); + } + + @Test + public void testActionClassWithoutNoArgConstructorThrows() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("needsArgs", NoDefaultConstructorAction.class).success("admin/users.ftl")); + + assertThatThrownBy(() -> load(registry)) + .isInstanceOf(ConfigurationException.class) + .satisfies(e -> assertThat(messagesOf(e)).anyMatch(message -> + message.contains(NoDefaultConstructorAction.class.getName()) + && message.contains("needsArgs") && message.contains("no-arg constructor"))); + } + + private static List messagesOf(Throwable throwable) { + List messages = new ArrayList<>(); + for (Throwable current = throwable; current != null; current = current.getCause()) { + messages.add(String.valueOf(current.getMessage())); + } + return messages; + } + + static class NonPublicAction { + public String execute() { + return "success"; + } + } + + public static class NoDefaultConstructorAction { + public NoDefaultConstructorAction(String name) { + } + + public String execute() { + return "success"; + } + } + + @Test + public void testClashWithXmlThrows() { + ActionRegistry registry = usersRegistry(); + assertThatThrownBy(() -> load(registry, "struts-boot-clash.xml")) + .isInstanceOf(ConfigurationException.class) + .rootCause().hasMessageContaining("xml-pkg").hasMessageContaining("users"); + } + + @Test + public void testSameNamespaceAsXmlWithDistinctNamesLoads() { + ActionRegistry registry = new ActionRegistry(); + registry.namespace("/admin", ns -> ns.action("list", UserAction.class).method("list").success("admin/users.ftl")); + + Configuration configuration = load(registry, "struts-boot-clash.xml"); + + assertThat(configuration.getRuntimeConfiguration().getActionConfig("/admin", "users")).isNotNull(); + assertThat(configuration.getRuntimeConfiguration().getActionConfig("/admin", "list")).isNotNull(); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java new file mode 100644 index 0000000000..c3009eb250 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java @@ -0,0 +1,256 @@ +/* + * 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.struts2.boot; + +import org.apache.struts2.boot.fixture.BrokenApp; +import org.apache.struts2.boot.fixture.HelloApp; +import org.apache.struts2.boot.fixture.UserAction; +import org.apache.struts2.config.ConfigurationException; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assume.assumeTrue; + +public class StrutsBootTomcatTest { + + private static final HttpClient CLIENT = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + private static StrutsBootServer server; + + @BeforeClass + public static void startServer() { + server = StrutsBoot.start(HelloApp.class, "--struts.boot.port=0"); + } + + @AfterClass + public static void stopServer() { + if (server != null) { + server.close(); + } + } + + @Test + public void testRendersFreemarkerTemplate() throws Exception { + assertThat(get(server.baseUrl() + "/hello.action").statusCode()).isEqualTo(200); + } + + @Test + public void testStrutsTagRenders() throws Exception { + assertThat(get(server.baseUrl() + "/hello.action").body()).contains("Hello Boot"); + } + + @Test + public void testPostOnlyRejectsGet() throws Exception { + String url = server.baseUrl() + "/admin/saveUser.action"; + + assertThat(get(url).statusCode()).isEqualTo(405); + + HttpResponse post = post(url); + assertThat(post.statusCode()).isEqualTo(302); + assertThat(post.headers().firstValue("Location")).hasValueSatisfying(location -> + assertThat(location).endsWith("/admin/users.action")); + } + + @Test + public void testUnregisteredMethodNotInvokable() throws Exception { + UserAction.deleteInvoked = false; + assertThat(get(server.baseUrl() + "/admin/users!list.action").statusCode()).isEqualTo(200); + + HttpResponse response = get(server.baseUrl() + "/admin/users!delete.action"); + + assertThat(UserAction.deleteInvoked).isFalse(); + assertThat(response.statusCode()).isNotEqualTo(200); + } + + @Test + public void testUnregisteredMethodNotInvokableViaMethodPrefix() throws Exception { + UserAction.deleteInvoked = false; + get(server.baseUrl() + "/xml/legacyUsers.action?method:delete="); + assertThat(UserAction.deleteInvoked).isTrue(); + + UserAction.deleteInvoked = false; + HttpResponse response = get(server.baseUrl() + "/admin/users.action?method:delete="); + + assertThat(UserAction.deleteInvoked).isFalse(); + assertThat(response.statusCode()).isNotEqualTo(200); + } + + @Test + public void testErrorPageHidesStackTraceAndServerInfo() throws Exception { + HttpResponse response = get(server.baseUrl() + "/boom.action"); + + assertThat(response.statusCode()).isEqualTo(500); + assertThat(response.body()).doesNotContain("at org.apache").doesNotContain("Apache Tomcat/"); + } + + @Test + public void testAddressRestrictsListeningInterface() throws Exception { + try (StrutsBootServer local = StrutsBoot.start(HelloApp.class, "--struts.boot.port=0", "--struts.boot.address=127.0.0.1")) { + assertThat(get("http://127.0.0.1:" + local.port() + "/hello.action").statusCode()).isEqualTo(200); + + Optional external = nonLoopbackIpv4Address(); + assumeTrue("no non-loopback IPv4 interface to probe", external.isPresent()); + assertThatThrownBy(() -> { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(external.get(), local.port()), 2000); + } + }).isInstanceOf(IOException.class); + } + } + + @Test + public void testXmlAndRegistryCoexist() throws Exception { + assertThat(get(server.baseUrl() + "/xml/legacy.action").statusCode()).isEqualTo(200); + assertThat(get(server.baseUrl() + "/hello.action").statusCode()).isEqualTo(200); + } + + @Test + public void testBaseDirFilesAreNotServed() throws Exception { + Files.writeString(server.baseDir().resolve("webapp").resolve("doc-base-file.txt"), "public"); + Files.writeString(server.baseDir().resolve("base-dir-file.txt"), "not public"); + + assertThat(get(server.baseUrl() + "/doc-base-file.txt").statusCode()).isEqualTo(200); + assertThat(get(server.baseUrl() + "/base-dir-file.txt").statusCode()).isNotEqualTo(200); + } + + @Test + public void testConfigurationErrorStopsTomcatAndCleansBaseDir() throws Exception { + Set before = bootTempDirs(); + + assertThatThrownBy(() -> StrutsBoot.start(BrokenApp.class, "--struts.boot.port=0")) + .isInstanceOf(ConfigurationException.class) + .satisfies(e -> assertThat(messagesOf(e)).anyMatch(message -> message.contains("templates/missing.ftl"))); + + assertThat(bootTempDirs()).isEqualTo(before); + } + + @Test + public void testBusyPortFailsCleanly() throws Exception { + Set before = bootTempDirs(); + try (ServerSocket busy = new ServerSocket(0)) { + String portArgument = "--struts.boot.port=" + busy.getLocalPort(); + assertThatThrownBy(() -> StrutsBoot.start(HelloApp.class, portArgument)) + .isInstanceOf(ConfigurationException.class); + } + + assertThat(bootTempDirs()).isEqualTo(before); + } + + @Test + public void testContextPathAppliesToActionsAndRedirects() throws Exception { + try (StrutsBootServer ctx = StrutsBoot.start(HelloApp.class, "--struts.boot.port=0", "--struts.boot.context-path=/ctx")) { + assertThat(ctx.baseUrl()).endsWith("/ctx"); + assertThat(get("http://127.0.0.1:" + ctx.port() + "/ctx/hello.action").statusCode()).isEqualTo(200); + + HttpResponse post = post("http://127.0.0.1:" + ctx.port() + "/ctx/admin/saveUser.action"); + assertThat(post.headers().firstValue("Location")).hasValueSatisfying(location -> + assertThat(location).contains("/ctx/admin/users.action")); + } + } + + @Test + public void testCloseDeletesBaseDir() { + StrutsBootServer other = StrutsBoot.start(HelloApp.class, "--struts.boot.port=0"); + Path baseDir = other.baseDir(); + assertThat(baseDir).exists(); + + other.close(); + other.close(); + + assertThat(Files.exists(baseDir)).isFalse(); + } + + @Test + public void testBaseDirIsOwnerOnly() throws IOException { + assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + + assertThat(Files.getPosixFilePermissions(server.baseDir())) + .isEqualTo(PosixFilePermissions.fromString("rwx------")); + } + + private static HttpResponse get(String url) throws IOException, InterruptedException { + return CLIENT.send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + private static HttpResponse post(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString("")) + .build(); + return CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private static Optional nonLoopbackIpv4Address() throws SocketException { + return NetworkInterface.networkInterfaces() + .filter(StrutsBootTomcatTest::isUp) + .flatMap(NetworkInterface::inetAddresses) + .filter(address -> address instanceof Inet4Address && !address.isLoopbackAddress()) + .findFirst(); + } + + private static boolean isUp(NetworkInterface networkInterface) { + try { + return networkInterface.isUp() && !networkInterface.isLoopback(); + } catch (SocketException e) { + return false; + } + } + + private static Set bootTempDirs() throws IOException { + try (Stream entries = Files.list(Path.of(System.getProperty("java.io.tmpdir")))) { + return entries.filter(path -> path.getFileName().toString().startsWith("struts-boot-")).collect(Collectors.toSet()); + } + } + + private static Stream messagesOf(Throwable throwable) { + Stream.Builder messages = Stream.builder(); + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current.getMessage() != null) { + messages.add(current.getMessage()); + } + } + return messages.build(); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/fixture/BrokenApp.java b/boot/src/test/java/org/apache/struts2/boot/fixture/BrokenApp.java new file mode 100644 index 0000000000..6712ffb241 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/BrokenApp.java @@ -0,0 +1,30 @@ +/* + * 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.struts2.boot.fixture; + +import org.apache.struts2.boot.ActionRegistry; +import org.apache.struts2.boot.StrutsBootApplication; + +public class BrokenApp implements StrutsBootApplication { + + @Override + public void actions(ActionRegistry actions) { + actions.action("broken", HelloAction.class).success("missing.ftl"); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/fixture/HelloAction.java b/boot/src/test/java/org/apache/struts2/boot/fixture/HelloAction.java new file mode 100644 index 0000000000..82d1b3bf1b --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/HelloAction.java @@ -0,0 +1,30 @@ +/* + * 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.struts2.boot.fixture; + +public class HelloAction { + + public String execute() { + return "success"; + } + + public String getMessage() { + return "Hello Boot"; + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java b/boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java new file mode 100644 index 0000000000..541b5a6e83 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.struts2.boot.fixture; + +import org.apache.struts2.boot.ActionRegistry; +import org.apache.struts2.boot.StrutsBootApplication; + +import static org.apache.struts2.boot.HttpMethods.POST; + +public class HelloApp implements StrutsBootApplication { + + @Override + public void actions(ActionRegistry actions) { + actions.action("hello", HelloAction.class).success("hello.ftl"); + actions.action("boom", ThrowingAction.class).success("hello.ftl"); + actions.namespace("/admin", admin -> { + admin.action("users", UserAction.class).method("list").success("admin/users.ftl"); + admin.action("saveUser", UserAction.class).method("save").httpMethods(POST).redirectAction("success", "users"); + }); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/fixture/ThrowingAction.java b/boot/src/test/java/org/apache/struts2/boot/fixture/ThrowingAction.java new file mode 100644 index 0000000000..57e5d9cd82 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/ThrowingAction.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.struts2.boot.fixture; + +public class ThrowingAction { + + public String execute() { + throw new IllegalStateException("boom"); + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java new file mode 100644 index 0000000000..512f304ed4 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java @@ -0,0 +1,41 @@ +/* + * 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.struts2.boot.fixture; + +public class UserAction { + + public static volatile boolean deleteInvoked; + + public String execute() { + return "success"; + } + + public String list() { + return "success"; + } + + public String save() { + return "success"; + } + + public String delete() { + deleteInvoked = true; + return "success"; + } +} diff --git a/boot/src/test/resources/struts-boot-clash.xml b/boot/src/test/resources/struts-boot-clash.xml new file mode 100644 index 0000000000..04482e7238 --- /dev/null +++ b/boot/src/test/resources/struts-boot-clash.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/boot/src/test/resources/struts.properties b/boot/src/test/resources/struts.properties new file mode 100644 index 0000000000..ec4c6e8b93 --- /dev/null +++ b/boot/src/test/resources/struts.properties @@ -0,0 +1,17 @@ +# 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. +struts.enable.DynamicMethodInvocation=true diff --git a/boot/src/test/resources/struts.xml b/boot/src/test/resources/struts.xml new file mode 100644 index 0000000000..4a759d7bdc --- /dev/null +++ b/boot/src/test/resources/struts.xml @@ -0,0 +1,34 @@ + + + + + + + /templates/hello.ftl + + + /templates/hello.ftl + + + diff --git a/boot/src/test/resources/templates/admin/users.ftl b/boot/src/test/resources/templates/admin/users.ftl new file mode 100644 index 0000000000..1d2feeff8e --- /dev/null +++ b/boot/src/test/resources/templates/admin/users.ftl @@ -0,0 +1,21 @@ +<#-- +/* + * 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. + */ +--> +

users

diff --git a/boot/src/test/resources/templates/hello.ftl b/boot/src/test/resources/templates/hello.ftl new file mode 100644 index 0000000000..be6490fc2e --- /dev/null +++ b/boot/src/test/resources/templates/hello.ftl @@ -0,0 +1,21 @@ +<#-- +/* + * 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. + */ +--> +

<@s.property value="message"/>

diff --git a/docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md b/docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md new file mode 100644 index 0000000000..9979d36795 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md @@ -0,0 +1,444 @@ +# WW-5791 Struts Boot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A newcomer adds one dependency, registers an action in Java, runs `main()`, and gets a FreeMarker page from embedded Tomcat. No XML, no Convention, no Spring. + +**Architecture:** +- A new top-level `boot/` module with three parts: + - a fluent registry that only records definitions + - a `ServletContextAwareConfigurationProvider` that turns those definitions into ordinary `PackageConfig`s + - a launcher that starts Tomcat, registers `StrutsPrepareAndExecuteFilter` with `configProviders=`, and passes the registry to the provider through a servlet-context attribute +- Core is untouched. + +**Tech Stack:** +- Java 17 +- Struts core 7.5.0-SNAPSHOT +- `tomcat-embed-core`: 10.1.x by default, 11.0.x under `-Pjakartaee11` +- FreeMarker +- JUnit 4, AssertJ, `java.net.http.HttpClient` +- `maven-shade-plugin` for the example app + +**Spec:** `docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md` + +## Global Constraints + +- No file under `core/` changes. If something can't be done through existing extension points, stop and raise it. +- No Spring artifact in `struts2-boot-starter`'s dependency tree. Check with `./mvnw -pl boot dependency:tree`. +- Registry config must equal what the equivalent `struts.xml` would produce. Never relax strict method invocation, `@StrutsParameter` or the allowlist. Never set `struts.enable.SlashesInActionNames` or `struts.devMode`. +- Never read `application.properties`. Settings come from `struts.properties`, with Boot keys `struts.boot.port` (default `8080`) and `struts.boot.context-path` (default `""`). `--key=value` arguments override the file. +- FreeMarker only. Result paths resolve to `/templates/` + path on the classpath. +- Tomcat follows core's Jakarta line. Root property `tomcat-embed.version` is `10.1.59`, and `11.0.27` in the `jakartaee11` profile. +- Configuration errors are `org.apache.struts2.config.ConfigurationException`, thrown at startup. +- Tests are JUnit 4 (`org.junit.Test`). JUnit 5 is never used. +- Package: `org.apache.struts2.boot`. Artifact: `org.apache.struts:struts2-boot-starter`. +- Tomcat base dir: `Files.createTempDirectory("struts-boot-" + UUID.randomUUID())`, deleted recursively on close. +- Default to no comments (see `~/.claude/CLAUDE.md`). Every new file carries the ASF license header, copied from any existing source file. + +## Review Focus + +1. **Struts tags inside a template.** `<@s.property value="message"/>` must render. `FreemarkerManager` builds a `TaglibFactory`, which needs `jakarta.servlet.jsp` classes that `tomcat-embed-core` doesn't ship. Pinned in Task 4 `testStrutsTagRenders`. +2. **Startup failure leaves nothing behind.** A `ConfigurationException` or a busy port must not leave a running Tomcat, a hung JVM or a leftover temp dir. Pinned in Task 4 `testConfigurationErrorStopsTomcatAndCleansBaseDir` and `testBusyPortFailsCleanly`. +3. **Non-empty context path.** Actions answer under `/ctx/...`, and `redirectAction` carries the context path. Pinned in Task 4 `testContextPathAppliesToActionsAndRedirects`. +4. **`namespace("/admin")` called twice.** Both calls land in one package, and a duplicate name across the two calls throws. Pinned in Task 1 `testNamespaceDeclaredTwiceMerges` and `testDuplicateAcrossRepeatedNamespaceThrows`. +5. **Argument precedence.** `--struts.boot.port=9090` beats `struts.boot.port=8081` in `struts.properties`, and unknown arguments are ignored rather than fatal. Pinned in Task 3. + +--- + +## File Structure + +``` +pom.xml modify: boot; tomcat-embed.version property + jakartaee11 override +parent/pom.xml modify: dependencyManagement for tomcat-embed-core, struts2-boot-starter +bom/pom.xml modify: struts2-boot-starter entry +boot/pom.xml create +boot/src/main/java/org/apache/struts2/boot/ + StrutsBootApplication.java user-facing: void actions(ActionRegistry) + ActionRegistry.java user-facing: top-level actions + namespace(...) + NamespaceActions.java user-facing: actions + interceptors inside one namespace + ActionDefinition.java user-facing: fluent per-action calls + HttpMethods.java user-facing: static GET/POST/... aliases for HttpMethod + RegistryConfigurationProvider.java internal: registry -> PackageConfig, all validation + BootSettings.java internal: struts.properties + args + StrutsBoot.java user-facing: run(...) / start(...) + StrutsBootServer.java user-facing: running-server handle +boot/src/test/java/org/apache/struts2/boot/... tests + fixtures +boot/src/test/resources/templates/... fixture templates +apps/pom.xml modify: boot-hello +apps/boot-hello/... create: example app, shade config, smoke test +``` + +--- + +### Task 1: Module scaffolding and the fluent registry + +The registry only records definitions. It checks the rules that need no Struts configuration: slashes, `.ftl`, and duplicates inside the registry. Everything that needs Struts belongs to Task 2. + +**Files:** +- Modify: `pom.xml` + - add `boot` between `plugins` and `apps` + - add `10.1.59` to the properties + - add `11.0.27` to the `jakartaee11` profile properties +- Modify: `parent/pom.xml`: in `dependencyManagement`, add `org.apache.tomcat.embed:tomcat-embed-core:${tomcat-embed.version}` and `org.apache.struts:struts2-boot-starter:${project.version}` +- Modify: `bom/pom.xml`: add `struts2-boot-starter`, following the shape of the existing entries +- Create: `boot/pom.xml` + - parent `struts2-parent`, `relativePath` `../parent/pom.xml` + - artifactId `struts2-boot-starter`, name `Struts 2 Boot Starter` + - compile dependencies: `struts2-core`, `tomcat-embed-core` + - test dependencies: junit, assertj-core +- Create: `StrutsBootApplication.java`, `ActionRegistry.java`, `NamespaceActions.java`, `ActionDefinition.java`, `HttpMethods.java` +- Test: `boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java` + +`plugins/jasperreports7` keeps its own `tomcat-embed.version` property. Leave it alone. + +**Interfaces:** +- Produces: + - `interface StrutsBootApplication { void actions(ActionRegistry actions); }` + - `final class ActionRegistry`: + - `public static final String SERVLET_CONTEXT_ATTRIBUTE = ActionRegistry.class.getName()` + - `ActionDefinition action(String name, Class actionClass)`: namespace `""` + - `ActionRegistry namespace(String namespace, Consumer body)` + - `Collection namespaces()`: insertion order; `""` is included only when used + - `final class NamespaceActions`: + - `ActionDefinition action(String name, Class actionClass)` + - `NamespaceActions interceptors(String stackName)` + - `String namespace()` + - `Optional interceptorStack()` + - `List actions()` + - `final class ActionDefinition`: + - fluent methods, each returning `this`: + - `method(String)` + - `success(String templatePath)` + - `result(String name, String templatePath)` + - `redirectAction(String resultName, String actionName)` + - `httpMethods(HttpMethod...)` + - `interceptors(String stackName)` + - read accessors: `name()`, `actionClass()`, `methodName()` (default `"execute"`), `results()` (`Map`, insertion order), `httpMethodSet()`, `interceptorStack()` (`Optional`) + - `record ResultDefinition(Kind kind, String value)` nested in `ActionDefinition`, with `enum Kind { TEMPLATE, REDIRECT_ACTION }` + - `final class HttpMethods`: `public static final HttpMethod GET = HttpMethod.GET`, and the same for `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`. Users can `import static ...HttpMethods.POST`. + +- [ ] **Step 1: Write the failing tests** in `ActionRegistryTest`: + +```java +@Test public void testTopLevelActionGoesToEmptyNamespace() { + ActionRegistry r = new ActionRegistry(); + r.action("hello", Object.class).success("hello.ftl"); + assertThat(r.namespaces()).extracting(NamespaceActions::namespace).containsExactly(""); + ActionDefinition a = r.namespaces().iterator().next().actions().get(0); + assertThat(a.methodName()).isEqualTo("execute"); + assertThat(a.results()).containsEntry("success", new ResultDefinition(Kind.TEMPLATE, "hello.ftl")); +} +@Test public void testNamespaceDeclaredTwiceMerges() { + ActionRegistry r = new ActionRegistry(); + r.namespace("/admin", n -> n.action("a", Object.class)); + r.namespace("/admin", n -> n.action("b", Object.class)); + assertThat(r.namespaces()).hasSize(1); + assertThat(r.namespaces().iterator().next().actions()).extracting(ActionDefinition::name).containsExactly("a", "b"); +} +@Test public void testDuplicateAcrossRepeatedNamespaceThrows() { + ActionRegistry r = new ActionRegistry(); + r.namespace("/admin", n -> n.action("a", Object.class)); + assertThatThrownBy(() -> r.namespace("/admin", n -> n.action("a", Object.class))) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("/admin").hasMessageContaining("a"); +} +@Test public void testDuplicateTopLevelThrows() // two r.action("hello", ...) -> ConfigurationException +@Test public void testSlashInActionNameThrows() { // r.action("users/save", ...) -> ConfigurationException, message contains "namespace" +@Test public void testNonFtlTemplateThrows() // .success("hello.jsp") -> ConfigurationException, message contains "FreeMarker" +@Test public void testFluentCallsAreRecorded() { + ActionDefinition a = new ActionRegistry().action("save", Object.class) + .method("save").httpMethods(HttpMethods.POST) + .result("input", "form.ftl").redirectAction("success", "users").interceptors("paramsPrepareParamsStack"); + assertThat(a.methodName()).isEqualTo("save"); + assertThat(a.httpMethodSet()).containsExactly(HttpMethod.POST); + assertThat(a.results()).containsEntry("success", new ResultDefinition(Kind.REDIRECT_ACTION, "users")); + assertThat(a.interceptorStack()).contains("paramsPrepareParamsStack"); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -am -Dtest=ActionRegistryTest -Dsurefire.failIfNoSpecifiedTests=false > target/boot-t1.log 2>&1; echo $?` + - Expected: non-zero exit, with a compilation failure for the missing classes. + +- [ ] **Step 3: Implement** the five classes, the POM changes and the module. + - Duplicate and slash checks run when `action(...)` is called. + - The `.ftl` check runs in `result(...)` and `success(...)`. + +- [ ] **Step 4: Run the tests to verify they pass** + - Same command. Expected: exit 0, `Tests run: 7, Failures: 0`. + - Then run `./mvnw -pl boot dependency:tree | grep -i spring`. Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add pom.xml parent/pom.xml bom/pom.xml boot/ +git commit -m "WW-5791 feat(boot): add struts2-boot-starter module with fluent action registry" +``` + +--- + +### Task 2: Registry → PackageConfig provider + +**Files:** +- Create: `boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java` +- Test: `boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java` +- Test fixtures: + - `boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java`: methods `execute`, `list`, `save`, `delete` + - `boot/src/test/resources/templates/hello.ftl` + - `boot/src/test/resources/templates/admin/users.ftl` + - `boot/src/test/resources/struts-boot-clash.xml`: package `xml-pkg`, namespace `/admin`, action `users` + +**Interfaces:** +- Consumes: `ActionRegistry`, `NamespaceActions`, `ActionDefinition`, `ResultDefinition` from Task 1. +- Produces: `public class RegistryConfigurationProvider implements ServletContextAwareConfigurationProvider`, with: + - `public RegistryConfigurationProvider()`: used by `Dispatcher` through `configProviders`. `initWithContext` reads `ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE`. If the attribute is missing, it throws `ConfigurationException`. + - `RegistryConfigurationProvider(ActionRegistry registry)`: package-private, for tests. + - Package names: `struts-boot` for namespace `""`, and `"struts-boot" + namespace` otherwise, for example `struts-boot/admin`. + +Translation rules (from the spec): +- Each package uses `addParent(configuration.getPackageConfig("struts-default"))` and the definition's namespace. `strictMethodInvocation` keeps its default (`true`). +- Each action is `new ActionConfig.Builder(pkgName, name, actionClass.getName())`, with `methodName(m)` and `addAllowedMethod(m)`. + - If `httpMethodSet()` is non-empty, call `addHttpMethods(set)`. + - The interceptor stack comes from the action first, then the namespace. If either is set, call `addInterceptors(InterceptorBuilder.constructInterceptorReference(pkgBuilder, stack, Map.of(), null, objectFactory))`. If neither is set, add no interceptors, so the package default (`defaultStack`) applies. +- A `TEMPLATE` result becomes `new ResultConfig.Builder(name, freemarkerClass).addParam("location", "/templates/" + path)`. +- A `REDIRECT_ACTION` result becomes `new ResultConfig.Builder(name, redirectActionClass).addParam("actionName", target)`. +- Result classes come from `strutsDefault.getAllResultTypeConfigs().get("freemarker" | "redirectAction").getClassName()`. + +Validation, each a `ConfigurationException` thrown from `loadPackages()`: +- The template `templates/` is missing on the classpath. Check with `ClassLoaderUtil.getResource`. +- An unknown stack name. `constructInterceptorReference` already throws this. +- A non-abstract package already in `configuration.getPackageConfigs()` has the same namespace and an action with the same name. The message names the namespace, the action and the XML package. + +- [ ] **Step 1: Write the failing tests.** + +Build the configuration the way `ScopedConstantsSubstitutionTest` does: + +```java +private Configuration load(ActionRegistry registry, String... extraXml) { + ConfigurationManager cm = new ConfigurationManager(Container.DEFAULT_NAME); + cm.addContainerProvider(new DefaultPropertiesProvider()); + cm.addContainerProvider(new StrutsXmlConfigurationProvider("struts-default.xml")); + for (String xml : extraXml) cm.addContainerProvider(new StrutsXmlConfigurationProvider(xml)); + cm.addContainerProvider(new StrutsBeanSelectionProvider()); + cm.addContainerProvider(new RegistryConfigurationProvider(registry)); + return cm.getConfiguration(); +} +``` + +Write these tests: +- `testPackageExtendsStrutsDefaultWithNamespace`: `getPackageConfig("struts-boot/admin")` has namespace `/admin`, parent `struts-default`, and `isStrictMethodInvocation()` true. +- `testMethodIsAddedToAllowedMethods`: action `users` with `.method("list")` gives `getAllowedMethods().isAllowed("list")` true and `isAllowed("delete")` false. +- `testTemplateResultIsAbsoluteFreemarker`: the `success` result's class name equals struts-default's `freemarker` type, and its `location` param is `/templates/admin/users.ftl`. +- `testRedirectActionResult`: the class is the `redirectAction` type, and the `actionName` param is `users`. +- `testHttpMethodsCarried`: `.httpMethods(POST)` gives `getHttpMethods()` containing exactly `POST`. +- `testNoStackMeansPackageDefault`: the action's `getInterceptors()` is empty, and the `RuntimeConfiguration`'s resolved config for the action has a non-empty interceptor list. +- `testNamedStackResolved`: `.interceptors("basicStack")` gives a non-empty interceptor list. +- `testUnknownStackThrows`: `.interceptors("noSuchStack")` makes `load(...)` throw `ConfigurationException`. +- `testMissingTemplateThrows`: `.success("missing.ftl")` throws a `ConfigurationException` whose message contains `templates/missing.ftl`. +- `testClashWithXmlThrows`: the registry defines `users` in `/admin`, and `load(registry, "struts-boot-clash.xml")` throws a `ConfigurationException` whose message contains `xml-pkg` and `users`. +- `testSameNamespaceAsXmlWithDistinctNamesLoads`: the registry defines `list` in `/admin` alongside the XML `users`, and both actions resolve through `getRuntimeConfiguration().getActionConfig("/admin", ...)`. + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -Dtest=RegistryConfigurationProviderTest > target/boot-t2.log 2>&1; echo $?` + - Expected: non-zero exit, because the provider class doesn't exist yet. + +- [ ] **Step 3: Implement `RegistryConfigurationProvider`.** + - `init(Configuration)` stores the configuration. + - `register(...)` is a no-op. + - `needsReload()` returns `false`. + - `loadPackages()` applies the rules above, using `configuration.getContainer().getInstance(ObjectFactory.class)` as the `ObjectFactory`. + +- [ ] **Step 4: Run the tests to verify they pass** + - Same command. Expected: exit 0, `Failures: 0`, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add boot/ +git commit -m "WW-5791 feat(boot): translate the action registry into Struts package configs" +``` + +--- + +### Task 3: Boot settings + +**Files:** +- Create: `boot/src/main/java/org/apache/struts2/boot/BootSettings.java` +- Test: `boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java` + +**Interfaces:** +- Produces `final class BootSettings` with: + - `static BootSettings load(String[] args)`, which reads `struts.properties` from the context class loader + - `static BootSettings load(Properties fileProperties, String[] args)`, for tests + - `int port()` + - `String contextPath()` +- Precedence: `--struts.boot.port=N` arguments, then `struts.properties`, then the defaults (`8080`, `""`). +- An argument that doesn't match `--struts.boot.=` is ignored. +- A port that isn't numeric throws `ConfigurationException`. + +- [ ] **Step 1: Write the failing tests** + - `testDefaults`: port is `8080` and context path is `""`. + - `testFileValue`: file `struts.boot.port=8081` gives `8081`. + - `testArgumentBeatsFile`: file `8081` and arg `--struts.boot.port=9090` give `9090`. + - `testUnknownArgumentsIgnored`: args `--foo=bar` and `positional` give the defaults with no exception. + - `testContextPathFromArgs`: `--struts.boot.context-path=/ctx` gives `/ctx`. + - `testNonNumericPortThrows`: `--struts.boot.port=abc` throws `ConfigurationException`. + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -Dtest=BootSettingsTest > target/boot-t3.log 2>&1; echo $?` + - Expected: non-zero exit (compilation failure). + +- [ ] **Step 3: Implement `BootSettings`.** + +- [ ] **Step 4: Run the tests to verify they pass** + - Same command. Expected: exit 0, 6 tests, `Failures: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add boot/ +git commit -m "WW-5791 feat(boot): read port and context path from struts.properties and arguments" +``` + +--- + +### Task 4: Launcher on embedded Tomcat, end to end + +**Files:** +- Create: `boot/src/main/java/org/apache/struts2/boot/StrutsBoot.java`, `boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java` +- Modify: `boot/pom.xml`. Add `jakarta.servlet.jsp:jakarta.servlet.jsp-api` at runtime scope, with the version the parent already manages, only if `testStrutsTagRenders` fails without it (see Review Focus 1). +- Test: `boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java` +- Test fixtures: + - `fixture/HelloApp.java`: `hello` → `hello.ftl`; `/admin` with `users` (`list`) → `admin/users.ftl`, and `saveUser` (`save`, `POST` only) with redirect `success` → `users` + - `fixture/HelloAction.java`: `getMessage()` returns `"Hello Boot"` + - `fixture/BrokenApp.java`: registers `.success("missing.ftl")` + - `hello.ftl`, containing `<@s.property value="message"/>` + - `boot/src/test/resources/struts.xml`: package `xml-pkg`, namespace `/xml`, action `legacy` with a freemarker result `/templates/hello.ftl` + - `boot/src/test/resources/struts.properties`: `struts.enable.DynamicMethodInvocation=true` + +**Interfaces:** +- Consumes `ActionRegistry` (Task 1), `RegistryConfigurationProvider` (Task 2), and `BootSettings` (Task 3). +- Produces: + - `public final class StrutsBoot`: + - `static void run(Class app, String... args)`: starts the server and blocks on `await()`. On a startup failure it rethrows, which a newcomer's `main` reports as a non-zero exit. + - `static StrutsBootServer start(Class app, String... args)`: returns once Tomcat is serving. + - `public final class StrutsBootServer implements AutoCloseable`: + - `int port()`: the actual bound port, which matters when `--struts.boot.port=0` + - `String baseUrl()`: `"http://127.0.0.1:" + port + contextPath` + - `Path baseDir()` + - `void await()` + - `void close()`: stops and destroys Tomcat, then deletes `baseDir` + +`start` sequence, following the pattern in `JasperReport7TomcatTest`: +1. Instantiate the app with its no-arg constructor and call `actions(registry)`. +2. Create the temp base dir. +3. Create `new Tomcat()` with `setBaseDir` and `setPort`, then `addContext(contextPath, baseDir)`. Put the registry into the context's servlet context under `SERVLET_CONTEXT_ATTRIBUTE` (through a `ServletContainerInitializer` or `context.getServletContext().setAttribute` before start). +4. Add a `FilterDef` for `StrutsPrepareAndExecuteFilter` with init param `configProviders=RegistryConfigurationProvider.class.getName()`, mapped to `/*`. +5. Start Tomcat. If the context fails to start (`context.getState() != LifecycleState.STARTED`), or anything throws, call `close()` and throw a `ConfigurationException` that carries the cause. +6. Register a shutdown hook that calls `close()`. `close()` must be idempotent. + +Tests: the class starts one `@BeforeClass` server with `StrutsBoot.start(HelloApp.class, "--struts.boot.port=0")` and calls `close()` in `@AfterClass`. Each failure-path test starts its own server. +- `testRendersFreemarkerTemplate`: `GET /hello.action` returns 200. +- `testStrutsTagRenders`: the same response body contains `Hello Boot`. +- `testPostOnlyRejectsGet`: + - `GET /admin/saveUser.action` returns 405. + - `POST` to the same URL returns 302, with a `Location` ending in `/admin/users.action`. + - Because the POST reaches the action, the GET rejection comes from the method restriction rather than a 404. +- `testUnregisteredMethodNotInvokable`: with DMI on through the fixture `struts.properties`, `GET /admin/users!delete.action` does not invoke `delete` (`UserAction` records invocations in a static flag the test resets). The response is not 200. +- `testXmlAndRegistryCoexist`: both `GET /xml/legacy.action` and `GET /hello.action` return 200. +- `testConfigurationErrorStopsTomcatAndCleansBaseDir`: + - `StrutsBoot.start(BrokenApp.class, "--struts.boot.port=0")` throws a `ConfigurationException` whose message contains `templates/missing.ftl`. + - The temp-dir glob `struts-boot-*` gains no new directory. Snapshot it before and after. +- `testBusyPortFailsCleanly`: + - Hold a `ServerSocket` on a free port, then `start` with that port. It throws. + - No new `struts-boot-*` temp dir remains. +- `testContextPathAppliesToActionsAndRedirects`: + - Start with `--struts.boot.port=0 --struts.boot.context-path=/ctx`. + - `GET /ctx/hello.action` returns 200. + - `POST /ctx/admin/saveUser.action` returns a `Location` containing `/ctx/admin/users.action`. +- `testCloseDeletesBaseDir`: after `close()`, `Files.exists(server.baseDir())` is false. + +Use `HttpClient` with `followRedirects(NEVER)`. + +- [ ] **Step 1: Write the failing tests and fixtures.** + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -Dtest=StrutsBootTomcatTest > target/boot-t4.log 2>&1; echo $?` + - Expected: non-zero exit (compilation failure). + +- [ ] **Step 3: Implement `StrutsBoot` and `StrutsBootServer`.** + - If `testStrutsTagRenders` fails with `NoClassDefFoundError: jakarta/servlet/jsp/...`, add the runtime dependency listed under Files. + +- [ ] **Step 4: Run the tests to verify they pass** + - Run the same command, then the whole module under both Jakarta lines: + - `./mvnw test -DskipAssembly -pl boot > target/boot-ee10.log 2>&1; echo $?` + - `./mvnw test -DskipAssembly -pl boot -Pjakartaee11 > target/boot-ee11.log 2>&1; echo $?` + - Expected: exit 0 for both, with `Failures: 0, Errors: 0`. + - Then run `./mvnw -pl boot dependency:tree -Pjakartaee11 | grep tomcat-embed-core`. Expected: `11.0.27`. Without the profile, expected: `10.1.59`. + +- [ ] **Step 5: Commit** + +```bash +git add boot/ +git commit -m "WW-5791 feat(boot): launch Struts on embedded Tomcat from main()" +``` + +--- + +### Task 5: Example app and runnable jar + +**Files:** +- Modify: `apps/pom.xml`: add `boot-hello` +- Create: `apps/boot-hello/pom.xml` + - artifactId `struts2-boot-hello`, packaging `jar` + - dependency `struts2-boot-starter` + - `maven-shade-plugin` bound to `package`, configured with: + - a `ManifestResourceTransformer` whose `mainClass` is `org.apache.struts2.boot.hello.HelloApp` + - a `ServicesResourceTransformer` + - signature-file excludes (`META-INF/*.SF`, `*.DSA`, `*.RSA`) +- Create: `apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloApp.java` and `HelloAction.java`. This is the spec's newcomer example, trimmed to `hello` only. +- Create: `apps/boot-hello/src/main/resources/templates/hello.ftl` +- Create: `apps/boot-hello/README.md`, covering the three newcomer steps, `java -jar`, `struts.boot.*` settings, and the shading limitation with plugins (two `struts-plugin.xml` files overwrite each other) +- Test: `apps/boot-hello/src/test/java/org/apache/struts2/boot/hello/HelloAppTest.java` + +**Interfaces:** +- Consumes `StrutsBoot.start`, `StrutsBootServer` (Task 4). + +- [ ] **Step 1: Write the failing smoke test.** `testHelloPageRenders` starts `HelloApp` on port 0, sends `GET /hello.action`, and expects 200 with the template's text. + +- [ ] **Step 2: Run it to verify it fails** + - Run: `./mvnw test -DskipAssembly -pl apps/boot-hello -am > target/boot-t5.log 2>&1; echo $?` + - Expected: non-zero exit. + +- [ ] **Step 3: Implement the app, the POM and the README.** + +- [ ] **Step 4: Verify the test and the runnable jar** + - Rerun the Step 2 command. Expected: exit 0. + - Build the jar: `./mvnw package -DskipAssembly -DskipTests -pl apps/boot-hello -am > target/boot-pkg.log 2>&1; echo $?` + - Start it: `java -jar apps/boot-hello/target/struts2-boot-hello-7.5.0-SNAPSHOT.jar --struts.boot.port=18080 &` + - Fetch the page: `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/hello.action`. Expected: `200`. + - Kill the process. The `struts-boot-*` temp dir from this run is gone. + +- [ ] **Step 5: Commit** + +```bash +git add apps/pom.xml apps/boot-hello/ +git commit -m "WW-5791 feat(boot): add boot-hello example app with runnable jar" +``` + +--- + +### Task 6: Full reactor check + +- [ ] **Step 1:** `./mvnw clean install -DskipAssembly > target/boot-reactor.log 2>&1; echo $?`. Expected: exit 0. + - The Tiles plugin may fail on unmodified `main` (see memory: velocity-tools). If it does, confirm that on `main` before blaming this branch. +- [ ] **Step 2:** `./mvnw clean install -DskipAssembly -Pjakartaee11 > target/boot-reactor-ee11.log 2>&1; echo $?`. Expected: exit 0. +- [ ] **Step 3:** `git status --short`. Expected: no stray `??` files from review agents. +- [ ] **Step 4:** Read `THREAT_MODEL.md` and confirm Boot stays inside the framework's existing trust boundaries: the registry produces nothing `struts.xml` couldn't, and the Tomcat defaults are stated in the README. Do this before any PR. + +## Out of scope for this plan + +- struts-site documentation +- the assembly module +- the 7.5.0 Version Notes entry + +These follow after merge. diff --git a/docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md b/docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md new file mode 100644 index 0000000000..7cac1e7878 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md @@ -0,0 +1,202 @@ +# WW-5791 — Struts Boot + +Ticket: [WW-5791](https://issues.apache.org/jira/browse/WW-5791). Fix version 7.5.0. + +## Intent + +A newcomer should get a working Struts application with almost no setup. Today the first page +needs a `web.xml`, a `struts.xml` (or the Convention plugin and its naming rules), a WAR build and +an external servlet container. Struts Boot cuts that to three steps: + +1. add one dependency, `struts-boot-starter` +2. write an action class and register it in code +3. run `main()` and open the page + +The first cut targets newcomers who are starting fresh. Running an existing Struts WAR as a +self-contained jar is a later step. + +## Goals + +- One dependency, one `main()` call and one action class are enough for a working page. +- Actions, results and views are registered in plain Java through a fluent API. There is no XML + and no classpath scanning. +- No Spring dependency anywhere in the starter's dependency tree. +- The application builds to a single runnable jar: `java -jar app.jar`. +- Everything a newcomer writes is ordinary Struts. The action classes, value stack, interceptors, + FreeMarker templates and `struts.properties` behave exactly as they do in a WAR. + +## Non-goals (parked in WW-5791's "Parked for later" list) + +- Running an existing WAR as a jar (the migration path) +- JSP views +- a Gradle plugin +- containers other than Tomcat +- a fluent API for defining interceptor stacks +- global results, global exception mappings and wildcard actions in the registry +- a bridge to Convention plugin annotations +- dev-mode reload of the registry +- a project generator or archetype +- health and info endpoints + +## Anti-goals + +- **A second configuration model.** The registry builds the same `PackageConfig` / `ActionConfig` / + `ResultConfig` objects that `struts.xml` produces. Whatever a registered action does must match + what the equivalent XML would do, including security defaults: strict method invocation, + `@StrutsParameter` enforcement and the allowlist. If Boot quietly relaxes any of these to make + the first page easier, it has failed, even if it works. The invariant: registry config is never + broader than the equivalent `struts.xml`. Where the two differ, the registry is narrower: a + registry action allows only its registered method, while an XML action also inherits the + package's global allowed methods (`execute,input,back,cancel,browse,save,delete,list,index` in + `struts-default`). +- **A Spring Boot look-alike that pulls in Spring.** +- **Core changes for Boot's convenience.** Boot uses only existing extension points (see + "Wiring"). If something can't be done that way, stop and raise it. Don't patch `Dispatcher`. +- **Clashing with Spring Boot's files.** Boot never reads `application.properties`. + +## The user's view + +```java +public class HelloApp implements StrutsBootApplication { + + public static void main(String[] args) { + StrutsBoot.run(HelloApp.class, args); + } + + @Override + public void actions(ActionRegistry actions) { + actions.action("hello", HelloAction.class) + .success("hello.ftl"); + + actions.namespace("/admin", admin -> { + admin.action("users", UserAction.class) + .method("list") + .success("admin/users.ftl"); + + admin.action("saveUser", UserAction.class) + .method("save") + .httpMethods(POST) + .result("input", "admin/userForm.ftl") + .redirectAction("success", "users"); + }); + } +} +``` + +The template lives at `src/main/resources/templates/hello.ftl`. Running `HelloApp` serves +`http://localhost:8080/hello` (or `/hello.action`, depending on `struts.action.extension`). + +## Registry semantics (decided) + +| Call | Builds | +|---|---| +| top-level `actions.action(...)` | an action in a Boot-owned package with namespace `""` | +| `namespace(ns, consumer)` | one `PackageConfig` with that namespace, extending `struts-default` | +| `action(name, Class)` | `ActionConfig` with `className`; method defaults to `execute` | +| `.method(name)` | `methodName`, and adds it to the allowed methods. `ActionConfig#isAllowedMethod` already accepts the configured method, so the explicit entry only makes it visible in `getAllowedMethods()`. No other method is added: a registry action does not inherit the package's global allowed methods | +| `.success(path)` | shorthand for `.result("success", path)` | +| `.result(name, path)` | a `freemarker` result whose location is `/templates/` + `path` | +| `.redirectAction(name, actionName)` | a `redirectAction` result | +| `.httpMethods(HttpMethod...)` | `ActionConfig.Builder#addHttpMethods`, i.e. the WW-3717 restriction (`org.apache.struts2.interceptor.httpmethod.HttpMethod`) | +| `.interceptors(stackName)` | on an action or a namespace: an interceptor-ref to an existing stack. The default is the package default, `defaultStack` | + +- **Views:** FreeMarker only in the first cut. A `.result` path that doesn't end in `.ftl` fails at + startup with a clear message. Paths are made absolute (`/templates/...`) on purpose: + `FreemarkerResult` resolves relative locations against the action's namespace, which newcomers + wouldn't expect. `FreemarkerManager` already includes `StrutsClassTemplateLoader`, so + `/templates/hello.ftl` resolves from the classpath with no extra loader. +- **Action names may not contain `/`** — a `ConfigurationException` at startup. Namespaces are + the way to nest. Boot does not flip `struts.enable.SlashesInActionNames`, which would route + differently from the same app as a WAR. +- **Errors fail at startup,** not on first request. Examples: duplicate action names in a + namespace, a missing template on the classpath, an unknown stack name. +- **`struts.xml` coexists.** `Dispatcher` already loads `struts-default.xml,struts-plugin.xml,struts.xml`, + and a missing `struts.xml` is tolerated (Convention-only apps already run that way). Registry + packages are added on top *(my call)*: + - Boot's packages get Boot-owned names (prefix `struts-boot`), so package names never collide + with XML. + - Sharing a namespace with XML is allowed. `struts-default` itself is namespace `""`. + - **Action clashes throw a `ConfigurationException` at startup**: the same action name in the + same namespace, registered twice in the registry or defined by both XML and the registry. + Custom providers load after the XML ones (`Dispatcher#init`), so the check reads the packages + already in the `Configuration`. + - Not at compile time: the registry is ordinary Java whose names and namespaces only exist once + `actions(...)` runs, and `struts.xml` is invisible to the compiler. Startup is the earliest + point where both sides are known. + +## Configuration (decided) + +- Framework constants come from the existing `struts.properties` on the classpath, which + `Dispatcher#init_LegacyStrutsProperties` already loads. +- Boot-only settings sit in the same file under the `struts.boot.*` prefix: + - `struts.boot.port` (default 8080) + - `struts.boot.context-path` (default `""`) +- Command-line `--struts.boot.port=9090` overrides the file. Supporting environment variables is + the builder's call. +- `struts.devMode` keeps its framework default (off). Boot does not switch it on. + +## Wiring (my call — the builder may change the mechanics, not the constraint) + +The rule is that Boot needs no core changes. It fits the existing extension points like this: + +1. `StrutsBoot.run` starts embedded Tomcat (`tomcat-embed-core`). It creates a context and + registers `StrutsPrepareAndExecuteFilter` on `/*` in code. The Tomcat line follows core's + Jakarta line: + - Tomcat 10.1 (Jakarta EE 10) by default + - Tomcat 11 under `-Pjakartaee11` + The version moves into parent/BOM management; today it is only a test-scope property in + `plugins/jasperreports7`. The Boot end-to-end tests run in both profiles. +2. It instantiates the application class and calls `actions(registry)`. It then stores the + resulting registry as a servlet-context attribute. +3. The filter gets the init param `configProviders=`. That provider + implements `ServletContextAwareConfigurationProvider`. `Dispatcher#init_CustomConfigurationProviders` + calls `initWithContext`, which reads the registry, and `loadPackages` builds the packages. +4. Tomcat's base directory is a fresh UUID-named directory under the system temp dir and is + deleted on shutdown (per the secure temp-file pattern in `CLAUDE.md`). +5. A JVM shutdown hook stops Tomcat cleanly. + +## Packaging (my call) + +- A new reactor module. I propose a top-level `boot/` next to `plugins/`, because Boot is not a + plugin and ships no `struts-plugin.xml`. The starter artifact is a jar that depends on + `struts2-core` and `tomcat-embed-core`. +- **Runnable jar:** the first cut documents a `maven-shade-plugin` setup and uses it in the + example app, rather than writing a custom Maven plugin. Known limitation: shading merges + same-named resources, so two plugin jars that each carry `struts-plugin.xml` overwrite each + other. The minimal scope has only core, so this doesn't bite yet. A nested-jar launcher or a + dedicated Maven plugin is follow-up work and must be solved before plugins are supported in + Boot apps. +- An example app goes under `apps/`. It is the newcomer path, and the integration test runs + against it. + +## Testing + +- JUnit 4 (`org.junit.Test`); this repo has no JUnit 5. AssertJ and Mockito are available. +- **Registry unit tests:** each fluent call produces the expected config entities, including + `method` being added to allowed methods, absolute template locations, and the startup errors. +- **End-to-end on embedded Tomcat**, following WW-5734's pattern in `plugins/jasperreports7`: + - start `StrutsBoot` on a random port + - GET a registered action and assert the rendered FreeMarker output + - assert that a POST-only action rejects GET + - assert that a method not registered on the action can't be invoked via `!method` or + `method:` syntax. Run this with `struts.enable.DynamicMethodInvocation=true`: DMI is off by + default, and with it off the test would pass without exercising the allowed-methods check + - make the POST-only check hit an action that GET can otherwise reach, so the rejection comes + from the method restriction and not from a 404 +- **Coexistence test:** a `struts.xml` action and a registry action in the same namespace both + answer. A duplicate name across the two fails startup. + +## Security + +Boot adds a new surface: the embedded container's defaults and the registry's translation into +config. Before the PR, read `THREAT_MODEL.md` and confirm that Boot sits within the framework's +existing trust boundaries. The registry must never produce config that XML couldn't. The anti-goal +above is the test for that. + +## Left to the builder + +- class and package names below `org.apache.struts2.boot`, apart from `StrutsBoot`, + `StrutsBootApplication` and `ActionRegistry` +- the exact Tomcat connector setup, logging and the startup banner +- whether environment variables override `struts.boot.*` +- the internal shape of the registry's builder objects diff --git a/parent/pom.xml b/parent/pom.xml index 4d74508ad0..42b672a34a 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -42,6 +42,18 @@ ${struts-annotations.version}
+ + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat-embed.version} + + + + org.apache.struts + struts2-boot-starter + ${project.version} + + org.freemarker freemarker diff --git a/pom.xml b/pom.xml index 02f06f2589..30d0bf2831 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,7 @@ jakarta core plugins + boot apps @@ -129,6 +130,7 @@ 3.4.16 6.2.19 2.0 + 10.1.59 3.1 5.3.8 1.13.2 @@ -166,6 +168,7 @@ 11.0.0 7.0.9 + 11.0.27