From 4728a608602ea3fc37b93c5d0f10d3241b2d6565 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:24:55 +0200 Subject: [PATCH 01/15] WW-5791 docs: design for Struts Boot Co-Authored-By: Claude Opus 5.5 --- .../2026-10-23-WW-5791-struts-boot-design.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md 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..ea379b8e51 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md @@ -0,0 +1,198 @@ +# 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. +- **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. `struts-default` packages run with strict method invocation, so without this the call would be rejected | +| `.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 From 1b6a1cac852a1c4b885953e1803aa27ed24a9f1d Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:33:19 +0200 Subject: [PATCH 02/15] WW-5791 docs: implementation plan for Struts Boot Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-23-WW-5791-struts-boot.md | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md 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. From 9ff45b55f06a5b30ed69892f4323a3ac9073d7dd Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:36:04 +0200 Subject: [PATCH 03/15] WW-5791 feat(boot): add struts2-boot-starter module with fluent action registry Co-Authored-By: Claude Opus 5.5 --- bom/pom.xml | 5 + boot/pom.xml | 55 +++++++++ .../apache/struts2/boot/ActionDefinition.java | 108 ++++++++++++++++++ .../apache/struts2/boot/ActionRegistry.java | 49 ++++++++ .../org/apache/struts2/boot/HttpMethods.java | 35 ++++++ .../apache/struts2/boot/NamespaceActions.java | 71 ++++++++++++ .../struts2/boot/StrutsBootApplication.java | 24 ++++ .../struts2/boot/ActionRegistryTest.java | 91 +++++++++++++++ parent/pom.xml | 12 ++ pom.xml | 3 + 10 files changed, 453 insertions(+) create mode 100644 boot/pom.xml create mode 100644 boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java create mode 100644 boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java create mode 100644 boot/src/main/java/org/apache/struts2/boot/HttpMethods.java create mode 100644 boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java create mode 100644 boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java 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..7fc3b35412 --- /dev/null +++ b/boot/pom.xml @@ -0,0 +1,55 @@ + + + + 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 + + + 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..d287e5ca52 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java @@ -0,0 +1,108 @@ +/* + * 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) { + 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/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..d18084d2a9 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java @@ -0,0 +1,71 @@ +/* + * 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"); + } + 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/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/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..6e552ac1ba --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java @@ -0,0 +1,91 @@ +/* + * 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() { + ActionRegistry r = new ActionRegistry(); + assertThatThrownBy(() -> r.action("hello", Object.class).success("hello.jsp")) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("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"); + } +} 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 From 788d9280b517a228bd51a2b1319ee3acf9943d89 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:39:55 +0200 Subject: [PATCH 04/15] WW-5791 feat(boot): translate the action registry into Struts package configs Co-Authored-By: Claude Opus 5.5 --- boot/pom.xml | 5 + .../boot/RegistryConfigurationProvider.java | 149 +++++++++++++ .../RegistryConfigurationProviderTest.java | 206 ++++++++++++++++++ .../struts2/boot/fixture/UserAction.java | 38 ++++ boot/src/test/resources/struts-boot-clash.xml | 29 +++ .../test/resources/templates/admin/users.ftl | 1 + boot/src/test/resources/templates/hello.ftl | 1 + 7 files changed, 429 insertions(+) create mode 100644 boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java create mode 100644 boot/src/test/resources/struts-boot-clash.xml create mode 100644 boot/src/test/resources/templates/admin/users.ftl create mode 100644 boot/src/test/resources/templates/hello.ftl diff --git a/boot/pom.xml b/boot/pom.xml index 7fc3b35412..93242450de 100644 --- a/boot/pom.xml +++ b/boot/pom.xml @@ -41,6 +41,11 @@ org.apache.tomcat.embed tomcat-embed-core + + jakarta.servlet.jsp + jakarta.servlet.jsp-api + test + junit junit 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..c164f59bbc --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java @@ -0,0 +1,149 @@ +/* + * 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.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.util.ClassLoaderUtil; +import org.apache.struts2.util.location.LocatableProperties; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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; + + 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) { + } + + @Override + public boolean needsReload() { + return false; + } + + @Override + public void destroy() { + } + + @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()); + + 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()); + pkg.addActionConfig(definition.name(), + actionConfig(packageName, namespace, definition, pkg, objectFactory, freemarker, redirectAction)); + } + configuration.addPackageConfig(packageName, pkg.build()); + } + } + + 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 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/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..0e9e82307c --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java @@ -0,0 +1,206 @@ +/* + * 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.ActionContext; +import org.junit.After; +import org.junit.Test; + +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 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() throws Exception { + 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 testClashWithXmlThrows() { + assertThatThrownBy(() -> load(usersRegistry(), "struts-boot-clash.xml")) + .isInstanceOf(ConfigurationException.class) + .rootCause().hasMessageContaining("xml-pkg").hasMessageContaining("users"); + } + + @Test + public void testSameNamespaceAsXmlWithDistinctNamesLoads() throws Exception { + 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/fixture/UserAction.java b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java new file mode 100644 index 0000000000..56a5bde331 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java @@ -0,0 +1,38 @@ +/* + * 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 String execute() { + return "success"; + } + + public String list() { + return "success"; + } + + public String save() { + return "success"; + } + + public String delete() { + 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/templates/admin/users.ftl b/boot/src/test/resources/templates/admin/users.ftl new file mode 100644 index 0000000000..e9544fbeea --- /dev/null +++ b/boot/src/test/resources/templates/admin/users.ftl @@ -0,0 +1 @@ +

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..6e7c1e6ac8 --- /dev/null +++ b/boot/src/test/resources/templates/hello.ftl @@ -0,0 +1 @@ +

hello

From cc1b9d9a3f5c7b286962cb788ec6fbe2fda016fe Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:43:08 +0200 Subject: [PATCH 05/15] WW-5791 feat(boot): read port and context path from struts.properties and arguments Co-Authored-By: Claude Opus 5.5 --- .../org/apache/struts2/boot/BootSettings.java | 95 +++++++++++++++++++ .../apache/struts2/boot/BootSettingsTest.java | 77 +++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 boot/src/main/java/org/apache/struts2/boot/BootSettings.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java 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..7632a4694c --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java @@ -0,0 +1,95 @@ +/* + * 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.Properties; + +public final class BootSettings { + + private static final String PREFIX = "struts.boot."; + private static final String PORT = PREFIX + "port"; + private static final String CONTEXT_PATH = PREFIX + "context-path"; + private static final String ARGUMENT_PREFIX = "--"; + + private final int port; + private final String contextPath; + + private BootSettings(int port, String contextPath) { + this.port = port; + this.contextPath = contextPath; + } + + 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, "8080")), + merged.getProperty(CONTEXT_PATH, "")); + } + + public int port() { + return port; + } + + public String contextPath() { + return contextPath; + } + + 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 int parsePort(String value) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + throw new ConfigurationException("Invalid " + PORT + ": '" + 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/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..f4fc3b7fb6 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java @@ -0,0 +1,77 @@ +/* + * 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 testNonNumericPortThrows() { + assertThatThrownBy(() -> BootSettings.load(new Properties(), new String[]{"--struts.boot.port=abc"})) + .isInstanceOf(ConfigurationException.class); + } +} From 9c63f6ef8701d6949cd4af4609c0a2aa7adfe44d Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:49:33 +0200 Subject: [PATCH 06/15] WW-5791 feat(boot): launch Struts on embedded Tomcat from main() Co-Authored-By: Claude Opus 5.5 --- boot/pom.xml | 2 +- .../boot/RegistryConfigurationProvider.java | 12 + .../org/apache/struts2/boot/StrutsBoot.java | 50 +++++ .../apache/struts2/boot/StrutsBootServer.java | 206 ++++++++++++++++++ .../RegistryConfigurationProviderTest.java | 9 + .../struts2/boot/StrutsBootTomcatTest.java | 175 +++++++++++++++ .../struts2/boot/fixture/BrokenApp.java | 30 +++ .../struts2/boot/fixture/HelloAction.java | 30 +++ .../apache/struts2/boot/fixture/HelloApp.java | 36 +++ .../struts2/boot/fixture/UserAction.java | 3 + boot/src/test/resources/struts.properties | 17 ++ boot/src/test/resources/struts.xml | 31 +++ boot/src/test/resources/templates/hello.ftl | 2 +- 13 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 boot/src/main/java/org/apache/struts2/boot/StrutsBoot.java create mode 100644 boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/fixture/BrokenApp.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/fixture/HelloAction.java create mode 100644 boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java create mode 100644 boot/src/test/resources/struts.properties create mode 100644 boot/src/test/resources/struts.xml diff --git a/boot/pom.xml b/boot/pom.xml index 93242450de..76593c0edf 100644 --- a/boot/pom.xml +++ b/boot/pom.xml @@ -44,7 +44,7 @@ jakarta.servlet.jsp jakarta.servlet.jsp-api - test + runtime junit diff --git a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java index c164f59bbc..ccfb87ff16 100644 --- a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java +++ b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java @@ -22,19 +22,23 @@ 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.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 { @@ -43,6 +47,7 @@ public class RegistryConfigurationProvider implements ServletContextAwareConfigu private ActionRegistry registry; private Configuration configuration; + private ProviderAllowlist providerAllowlist; public RegistryConfigurationProvider() { } @@ -77,6 +82,9 @@ public boolean needsReload() { @Override public void destroy() { + if (providerAllowlist != null) { + providerAllowlist.clearAllowlist(this); + } } @Override @@ -86,6 +94,7 @@ public void loadPackages() { 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(); @@ -95,11 +104,14 @@ public void loadPackages() { for (ActionDefinition definition : namespace.actions()) { assertNoClash(existing, namespace.namespace(), definition.name()); + 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, 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/StrutsBootServer.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java new file mode 100644 index 0000000000..910dd5cc00 --- /dev/null +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java @@ -0,0 +1,206 @@ +/* + * 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.servlets.DefaultServlet; +import org.apache.catalina.startup.Tomcat; +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.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +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 String contextPath; + private final AtomicBoolean closed = new AtomicBoolean(); + private final Thread shutdownHook = new Thread(this::close, "struts-boot-shutdown"); + + private StrutsBootServer(Tomcat tomcat, Path baseDir, String contextPath) { + this.tomcat = tomcat; + this.baseDir = baseDir; + this.contextPath = contextPath; + } + + static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { + Path baseDir = createBaseDir(); + Tomcat tomcat = new Tomcat(); + StrutsBootServer server = new StrutsBootServer(tomcat, baseDir, settings.contextPath()); + CapturingStrutsFilter filter = new CapturingStrutsFilter(); + try { + tomcat.setBaseDir(baseDir.toString()); + tomcat.setPort(settings.port()); + Context context = tomcat.addContext(settings.contextPath(), baseDir.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(); + server.assertStarted(context, filter); + } catch (Exception e) { + ConfigurationException failure = e instanceof ConfigurationException configurationException + ? configurationException + : new ConfigurationException("Struts Boot failed to start: " + e.getMessage(), e); + try { + server.close(); + } catch (RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + Runtime.getRuntime().addShutdownHook(server.shutdownHook); + return server; + } + + public int port() { + return tomcat.getConnector().getLocalPort(); + } + + public String baseUrl() { + return "http://127.0.0.1:" + port() + 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 void assertStarted(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); + } + if (tomcat.getConnector().getState() != LifecycleState.STARTED || port() < 0) { + throw new ConfigurationException("Struts Boot failed to start: connector state " + + tomcat.getConnector().getState() + " on port " + tomcat.getConnector().getPort()); + } + } + + private void removeShutdownHook() { + if (Thread.currentThread() == shutdownHook) { + return; + } + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException | IllegalArgumentException ignored) { + // JVM already shutting down, or the hook was never registered + } + } + + 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 { + return Files.createTempDirectory("struts-boot-" + UUID.randomUUID()); + } 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 (RuntimeException | ServletException e) { + failure.set(e); + throw e; + } + } + } +} diff --git a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java index 0e9e82307c..f0683b173c 100644 --- a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java @@ -29,6 +29,7 @@ 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; @@ -86,6 +87,14 @@ public void testPackageExtendsStrutsDefaultWithNamespace() { 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(); 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..be3f6d8299 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java @@ -0,0 +1,175 @@ +/* + * 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.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +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; + +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; + + HttpResponse response = get(server.baseUrl() + "/admin/users!delete.action"); + + assertThat(UserAction.deleteInvoked).isFalse(); + assertThat(response.statusCode()).isNotEqualTo(200); + } + + @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 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)) { + assertThatThrownBy(() -> StrutsBoot.start(HelloApp.class, "--struts.boot.port=" + busy.getLocalPort())) + .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(); + } + + 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 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..eaf21163a0 --- /dev/null +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java @@ -0,0 +1,36 @@ +/* + * 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.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/UserAction.java b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java index 56a5bde331..512f304ed4 100644 --- a/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java @@ -20,6 +20,8 @@ public class UserAction { + public static volatile boolean deleteInvoked; + public String execute() { return "success"; } @@ -33,6 +35,7 @@ public String save() { } public String delete() { + deleteInvoked = true; return "success"; } } 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..925f15ddc8 --- /dev/null +++ b/boot/src/test/resources/struts.xml @@ -0,0 +1,31 @@ + + + + + + + /templates/hello.ftl + + + diff --git a/boot/src/test/resources/templates/hello.ftl b/boot/src/test/resources/templates/hello.ftl index 6e7c1e6ac8..102b1e4a44 100644 --- a/boot/src/test/resources/templates/hello.ftl +++ b/boot/src/test/resources/templates/hello.ftl @@ -1 +1 @@ -

hello

+

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

From 9363c37ed36adeb9153780da1987e67e72d9d093 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:54:45 +0200 Subject: [PATCH 07/15] WW-5791 fix(boot): serve an empty docBase and capture every filter init failure Co-Authored-By: Claude Opus 5.5 --- .../apache/struts2/boot/StrutsBootServer.java | 22 +++++++++++++------ .../struts2/boot/StrutsBootTomcatTest.java | 8 +++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java index 910dd5cc00..4b42a731ed 100644 --- a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java @@ -63,7 +63,8 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { try { tomcat.setBaseDir(baseDir.toString()); tomcat.setPort(settings.port()); - Context context = tomcat.addContext(settings.contextPath(), baseDir.toString()); + 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); @@ -77,12 +78,11 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { ConfigurationException failure = e instanceof ConfigurationException configurationException ? configurationException : new ConfigurationException("Struts Boot failed to start: " + e.getMessage(), e); - try { - server.close(); - } catch (RuntimeException closeFailure) { - failure.addSuppressed(closeFailure); - } + server.closeAfterFailure(failure); throw failure; + } catch (Error e) { + server.closeAfterFailure(e); + throw e; } Runtime.getRuntime().addShutdownHook(server.shutdownHook); return server; @@ -120,6 +120,14 @@ public void close() { } } + private void closeAfterFailure(Throwable failure) { + try { + close(); + } catch (RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + private void assertStarted(Context context, CapturingStrutsFilter filter) { Throwable initFailure = filter.failure.get(); if (initFailure != null || context.getState() != LifecycleState.STARTED) { @@ -197,7 +205,7 @@ private static final class CapturingStrutsFilter extends StrutsPrepareAndExecute public void init(FilterConfig filterConfig) throws ServletException { try { super.init(filterConfig); - } catch (RuntimeException | ServletException e) { + } catch (Throwable e) { failure.set(e); throw e; } diff --git a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java index be3f6d8299..19ede906fd 100644 --- a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java @@ -86,6 +86,7 @@ public void testPostOnlyRejectsGet() throws Exception { @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"); @@ -99,6 +100,13 @@ public void testXmlAndRegistryCoexist() throws Exception { assertThat(get(server.baseUrl() + "/hello.action").statusCode()).isEqualTo(200); } + @Test + public void testBaseDirFilesAreNotServed() throws Exception { + Files.writeString(server.baseDir().resolve("base-dir-file.txt"), "not public"); + + assertThat(get(server.baseUrl() + "/base-dir-file.txt").statusCode()).isNotEqualTo(200); + } + @Test public void testConfigurationErrorStopsTomcatAndCleansBaseDir() throws Exception { Set before = bootTempDirs(); From 02bcb32741c0e7159ff8cb4dba3fa27439e299ed Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 12:59:54 +0200 Subject: [PATCH 08/15] WW-5791 feat(boot): add boot-hello example app with runnable jar Co-Authored-By: Claude Opus 5.5 --- apps/boot-hello/README.md | 56 ++++++++++++ apps/boot-hello/pom.xml | 90 +++++++++++++++++++ .../struts2/boot/hello/HelloAction.java | 30 +++++++ .../apache/struts2/boot/hello/HelloApp.java | 35 ++++++++ .../src/main/resources/templates/hello.ftl | 21 +++++ .../struts2/boot/hello/HelloAppTest.java | 45 ++++++++++ apps/pom.xml | 1 + .../test/resources/templates/admin/users.ftl | 20 +++++ boot/src/test/resources/templates/hello.ftl | 20 +++++ 9 files changed, 318 insertions(+) create mode 100644 apps/boot-hello/README.md create mode 100644 apps/boot-hello/pom.xml create mode 100644 apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloAction.java create mode 100644 apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloApp.java create mode 100644 apps/boot-hello/src/main/resources/templates/hello.ftl create mode 100644 apps/boot-hello/src/test/java/org/apache/struts2/boot/hello/HelloAppTest.java diff --git a/apps/boot-hello/README.md b/apps/boot-hello/README.md new file mode 100644 index 0000000000..2e36a61cc5 --- /dev/null +++ b/apps/boot-hello/README.md @@ -0,0 +1,56 @@ +# 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`. +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 +``` + +## 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 | + +## Defaults worth knowing + +- Tomcat listens on all interfaces, not only on localhost. +- `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 (for example with +Spring Boot's repackager or a `lib/` directory next to a thin jar) instead of shading. diff --git a/apps/boot-hello/pom.xml b/apps/boot-hello/pom.xml new file mode 100644 index 0000000000..d6b043b6d4 --- /dev/null +++ b/apps/boot-hello/pom.xml @@ -0,0 +1,90 @@ + + + + 4.0.0 + + org.apache.struts + struts2-apps + 7.5.0-SNAPSHOT + + + struts2-boot-hello + jar + 7.5.0-SNAPSHOT + Struts 2 Boot Hello + Struts Boot example application packaged as a runnable jar + + + + 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 + + + + + + *:* + + 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/boot/src/test/resources/templates/admin/users.ftl b/boot/src/test/resources/templates/admin/users.ftl index e9544fbeea..1d2feeff8e 100644 --- a/boot/src/test/resources/templates/admin/users.ftl +++ b/boot/src/test/resources/templates/admin/users.ftl @@ -1 +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 index 102b1e4a44..be6490fc2e 100644 --- a/boot/src/test/resources/templates/hello.ftl +++ b/boot/src/test/resources/templates/hello.ftl @@ -1 +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"/>

From bad539924c47a4515f73787b04da00605a6e2ad4 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 13:16:24 +0200 Subject: [PATCH 09/15] WW-5791 fix(boot): harden embedded Tomcat defaults and normalise boot settings Error pages no longer carry stack traces or the Tomcat version, the connector is opened as an explicit step after the Struts filter started, struts.boot.address restricts the listening interface (all interfaces stay the default), and struts.boot.context-path is normalised. Co-Authored-By: Claude Opus 5.5 --- .../org/apache/struts2/boot/BootSettings.java | 28 +++++++- .../apache/struts2/boot/StrutsBootServer.java | 66 ++++++++++++++----- .../apache/struts2/boot/BootSettingsTest.java | 43 ++++++++++++ .../struts2/boot/StrutsBootTomcatTest.java | 62 +++++++++++++++++ .../apache/struts2/boot/fixture/HelloApp.java | 1 + .../struts2/boot/fixture/ThrowingAction.java | 26 ++++++++ boot/src/test/resources/struts.xml | 3 + 7 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 boot/src/test/java/org/apache/struts2/boot/fixture/ThrowingAction.java diff --git a/boot/src/main/java/org/apache/struts2/boot/BootSettings.java b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java index 7632a4694c..534389a79b 100644 --- a/boot/src/main/java/org/apache/struts2/boot/BootSettings.java +++ b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.io.InputStream; +import java.util.Optional; import java.util.Properties; public final class BootSettings { @@ -29,14 +30,17 @@ public final class BootSettings { private static final String PREFIX = "struts.boot."; private static final String PORT = PREFIX + "port"; private static final String CONTEXT_PATH = PREFIX + "context-path"; + private static final String ADDRESS = 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) { + private BootSettings(int port, String contextPath, String address) { this.port = port; this.contextPath = contextPath; + this.address = address; } public static BootSettings load(String[] args) { @@ -51,7 +55,8 @@ public static BootSettings load(Properties fileProperties, String[] args) { } return new BootSettings( parsePort(merged.getProperty(PORT, "8080")), - merged.getProperty(CONTEXT_PATH, "")); + normaliseContextPath(merged.getProperty(CONTEXT_PATH, "")), + blankToNull(merged.getProperty(ADDRESS))); } public int port() { @@ -62,6 +67,10 @@ 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; @@ -73,6 +82,21 @@ private static void applyArgument(Properties target, String arg) { 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()); diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java index 4b42a731ed..14763c81e4 100644 --- a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java @@ -22,8 +22,10 @@ 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; @@ -45,24 +47,25 @@ public final class StrutsBootServer implements AutoCloseable { private final Tomcat tomcat; private final Path baseDir; - private final String contextPath; + 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, String contextPath) { + private StrutsBootServer(Tomcat tomcat, Path baseDir, BootSettings settings) { this.tomcat = tomcat; this.baseDir = baseDir; - this.contextPath = contextPath; + this.settings = settings; } static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { Path baseDir = createBaseDir(); Tomcat tomcat = new Tomcat(); - StrutsBootServer server = new StrutsBootServer(tomcat, baseDir, settings.contextPath()); + StrutsBootServer server = new StrutsBootServer(tomcat, baseDir, settings); CapturingStrutsFilter filter = new CapturingStrutsFilter(); try { tomcat.setBaseDir(baseDir.toString()); - tomcat.setPort(settings.port()); + hideErrorReportDetails(tomcat); Path docBase = Files.createDirectory(baseDir.resolve("webapp")); Context context = tomcat.addContext(settings.contextPath(), docBase.toString()); context.addServletContainerInitializer( @@ -73,7 +76,8 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { addStrutsFilter(context, filter); tomcat.start(); - server.assertStarted(context, filter); + assertContextStarted(context, filter); + server.openConnector(); } catch (Exception e) { ConfigurationException failure = e instanceof ConfigurationException configurationException ? configurationException @@ -89,11 +93,11 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { } public int port() { - return tomcat.getConnector().getLocalPort(); + return connector.getLocalPort(); } public String baseUrl() { - return "http://127.0.0.1:" + port() + contextPath; + return "http://" + urlHost() + ":" + port() + settings.contextPath(); } public Path baseDir() { @@ -128,29 +132,55 @@ private void closeAfterFailure(Throwable failure) { } } - private void assertStarted(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); - } - if (tomcat.getConnector().getState() != LifecycleState.STARTED || port() < 0) { + 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 " - + tomcat.getConnector().getState() + " on port " + tomcat.getConnector().getPort()); + + 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 | IllegalArgumentException ignored) { - // JVM already shutting down, or the hook was never registered + } 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); diff --git a/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java index f4fc3b7fb6..5f7b836bc7 100644 --- a/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java @@ -69,6 +69,49 @@ public void testContextPathFromArgs() { 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() { assertThatThrownBy(() -> BootSettings.load(new Properties(), new String[]{"--struts.boot.port=abc"})) diff --git a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java index 19ede906fd..7a8397f259 100644 --- a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java @@ -27,19 +27,27 @@ 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.Files; import java.nio.file.Path; +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 { @@ -94,6 +102,42 @@ public void testUnregisteredMethodNotInvokable() throws Exception { 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); @@ -102,8 +146,10 @@ public void testXmlAndRegistryCoexist() throws Exception { @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); } @@ -165,6 +211,22 @@ private static HttpResponse post(String url) throws IOException, Interru 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()); 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 index eaf21163a0..541b5a6e83 100644 --- a/boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java +++ b/boot/src/test/java/org/apache/struts2/boot/fixture/HelloApp.java @@ -28,6 +28,7 @@ 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/resources/struts.xml b/boot/src/test/resources/struts.xml index 925f15ddc8..4a759d7bdc 100644 --- a/boot/src/test/resources/struts.xml +++ b/boot/src/test/resources/struts.xml @@ -27,5 +27,8 @@ /templates/hello.ftl + + /templates/hello.ftl + From 914d7e7bab228dbd10ee15a1b9311501d3c138c4 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 13:16:24 +0200 Subject: [PATCH 10/15] WW-5791 fix(boot): verify action classes and registry arguments at startup Mirror XmlDocConfigurationProvider#verifyAction: a non-public action class or one without a public no-arg constructor fails startup. A null action class or an empty redirectAction target is rejected with a ConfigurationException. Co-Authored-By: Claude Opus 5.5 --- .../apache/struts2/boot/ActionDefinition.java | 4 ++ .../apache/struts2/boot/NamespaceActions.java | 3 ++ .../boot/RegistryConfigurationProvider.java | 18 +++++++ .../struts2/boot/ActionRegistryTest.java | 16 ++++++ .../RegistryConfigurationProviderTest.java | 49 +++++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java index d287e5ca52..3b9c1830fe 100644 --- a/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java +++ b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java @@ -68,6 +68,10 @@ public ActionDefinition result(String name, String templatePath) { } 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; } diff --git a/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java index d18084d2a9..39b418d92f 100644 --- a/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java +++ b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java @@ -42,6 +42,9 @@ public ActionDefinition action(String name, Class actionClass) { 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 + "'"); diff --git a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java index ccfb87ff16..699525210c 100644 --- a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java +++ b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java @@ -33,6 +33,7 @@ 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; @@ -104,6 +105,9 @@ public void loadPackages() { 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)); @@ -149,6 +153,20 @@ private ResultConfig resultConfig(ActionDefinition definition, String resultName }; } + 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()) diff --git a/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java index 6e552ac1ba..06e7d067f0 100644 --- a/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java @@ -78,6 +78,22 @@ public void testNonFtlTemplateThrows() { .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) diff --git a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java index f0683b173c..c19342c442 100644 --- a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java @@ -34,6 +34,9 @@ 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; @@ -195,6 +198,52 @@ public void testMissingTemplateThrows() { .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() { assertThatThrownBy(() -> load(usersRegistry(), "struts-boot-clash.xml")) From 54f5a7420ed131a47c4052dab75c342a166333b8 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 13:16:24 +0200 Subject: [PATCH 11/15] WW-5791 fix(boot): skip deploying boot-hello and mark its jar Multi-Release The shaded example jar merges dependency LICENSE/NOTICE files, so it is not deployed. The manifest declares Multi-Release because log4j-api and Tomcat are multi-release jars. The redundant version is inherited. Co-Authored-By: Claude Opus 5.5 --- apps/boot-hello/pom.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/boot-hello/pom.xml b/apps/boot-hello/pom.xml index d6b043b6d4..772e082054 100644 --- a/apps/boot-hello/pom.xml +++ b/apps/boot-hello/pom.xml @@ -29,10 +29,13 @@ struts2-boot-hello jar - 7.5.0-SNAPSHOT Struts 2 Boot Hello Struts Boot example application packaged as a runnable jar + + true + + org.apache.struts @@ -68,6 +71,9 @@ org.apache.struts2.boot.hello.HelloApp + + true + From 44526f83e83abc77d53c014d2088246e90de4ad5 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 13:16:25 +0200 Subject: [PATCH 12/15] WW-5791 docs: document bind address, logging and packaging in boot-hello README: struts.boot.address, error pages, the jar name, BOM usage, a logging backend note, and non-Spring packaging alternatives. Spec: fix the .method(name) row and state that registry config is never broader than the equivalent struts.xml. Co-Authored-By: Claude Opus 5.5 --- apps/boot-hello/README.md | 31 ++++++++++++++++--- .../2026-10-23-WW-5791-struts-boot-design.md | 8 +++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/boot-hello/README.md b/apps/boot-hello/README.md index 2e36a61cc5..c0e048a4c3 100644 --- a/apps/boot-hello/README.md +++ b/apps/boot-hello/README.md @@ -4,7 +4,9 @@ The smallest Struts Boot application: one action, one FreeMarker template, no `w ## Three steps -1. Depend on `org.apache.struts:struts2-boot-starter`. +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 @@ -32,6 +34,9 @@ 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. @@ -40,10 +45,12 @@ Settings are read from `struts.properties` on the classpath. A `--key=value` arg |----------------------------|---------|--------------------------------------------| | `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. +- 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. @@ -52,5 +59,21 @@ Settings are read from `struts.properties` on the classpath. A `--key=value` arg 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 (for example with -Spring Boot's repackager or a `lib/` directory next to a thin jar) instead of shading. +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/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 index ea379b8e51..7cac1e7878 100644 --- 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 @@ -44,7 +44,11 @@ self-contained jar is a later step. `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 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`. @@ -89,7 +93,7 @@ The template lives at `src/main/resources/templates/hello.ftl`. Running `HelloAp | 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. `struts-default` packages run with strict method invocation, so without this the call would be rejected | +| `.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 | From df38f43dbd22d5092d899e38a0fe0aba14cde4f8 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 13:41:35 +0200 Subject: [PATCH 13/15] WW-5791 docs: add Struts Boot to the threat model Co-Authored-By: Claude Opus 5.5 --- THREAT_MODEL.md | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) 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** From dd509bdc669ccfd15e7d01c755c2e92c0c84a835 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 23 Oct 2026 15:30:57 +0200 Subject: [PATCH 14/15] WW-5791 fix(boot): address Sonar findings in the starter The Tomcat base directory is created owner-only (rwx------) where the file system supports POSIX permissions, and the shutdown hook is registered inside the guarded start block so a failure there still closes the server. Settings keys get a _KEY suffix, the empty register() says why it is empty, and test lambdas keep a single throwing call. Co-Authored-By: Claude Opus 5.5 --- .../java/org/apache/struts2/boot/BootSettings.java | 14 +++++++------- .../boot/RegistryConfigurationProvider.java | 1 + .../org/apache/struts2/boot/StrutsBootServer.java | 10 ++++++++-- .../apache/struts2/boot/ActionRegistryTest.java | 4 ++-- .../org/apache/struts2/boot/BootSettingsTest.java | 4 +++- .../boot/RegistryConfigurationProviderTest.java | 7 ++++--- .../apache/struts2/boot/StrutsBootTomcatTest.java | 13 ++++++++++++- 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/boot/src/main/java/org/apache/struts2/boot/BootSettings.java b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java index 534389a79b..8accb60d4d 100644 --- a/boot/src/main/java/org/apache/struts2/boot/BootSettings.java +++ b/boot/src/main/java/org/apache/struts2/boot/BootSettings.java @@ -28,9 +28,9 @@ public final class BootSettings { private static final String PREFIX = "struts.boot."; - private static final String PORT = PREFIX + "port"; - private static final String CONTEXT_PATH = PREFIX + "context-path"; - private static final String ADDRESS = PREFIX + "address"; + 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; @@ -54,9 +54,9 @@ public static BootSettings load(Properties fileProperties, String[] args) { applyArgument(merged, arg); } return new BootSettings( - parsePort(merged.getProperty(PORT, "8080")), - normaliseContextPath(merged.getProperty(CONTEXT_PATH, "")), - blankToNull(merged.getProperty(ADDRESS))); + parsePort(merged.getProperty(PORT_KEY, "8080")), + normaliseContextPath(merged.getProperty(CONTEXT_PATH_KEY, "")), + blankToNull(merged.getProperty(ADDRESS_KEY))); } public int port() { @@ -101,7 +101,7 @@ private static int parsePort(String value) { try { return Integer.parseInt(value.trim()); } catch (NumberFormatException e) { - throw new ConfigurationException("Invalid " + PORT + ": '" + value + "' is not a number", e); + throw new ConfigurationException("Invalid " + PORT_KEY + ": '" + value + "' is not a number", e); } } diff --git a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java index 699525210c..dcce8a5b87 100644 --- a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java +++ b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java @@ -74,6 +74,7 @@ public void init(Configuration configuration) { @Override public void register(ContainerBuilder builder, LocatableProperties props) { + // the registry contributes packages only, no beans or constants } @Override diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java index 14763c81e4..21bd6c4b6a 100644 --- a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java @@ -33,8 +33,10 @@ 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; @@ -78,6 +80,7 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { tomcat.start(); assertContextStarted(context, filter); server.openConnector(); + Runtime.getRuntime().addShutdownHook(server.shutdownHook); } catch (Exception e) { ConfigurationException failure = e instanceof ConfigurationException configurationException ? configurationException @@ -88,7 +91,6 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { server.closeAfterFailure(e); throw e; } - Runtime.getRuntime().addShutdownHook(server.shutdownHook); return server; } @@ -196,7 +198,11 @@ private static void addStrutsFilter(Context context, CapturingStrutsFilter filte private static Path createBaseDir() { try { - return Files.createTempDirectory("struts-boot-" + UUID.randomUUID()); + String prefix = "struts-boot-" + UUID.randomUUID(); + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + return Files.createTempDirectory(prefix, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + } + return Files.createTempDirectory(prefix); } catch (IOException e) { throw new ConfigurationException("Cannot create the Tomcat base directory", 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 index 06e7d067f0..5ef5a3ef29 100644 --- a/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java @@ -73,8 +73,8 @@ public void testSlashInActionNameThrows() { @Test public void testNonFtlTemplateThrows() { - ActionRegistry r = new ActionRegistry(); - assertThatThrownBy(() -> r.action("hello", Object.class).success("hello.jsp")) + ActionDefinition hello = new ActionRegistry().action("hello", Object.class); + assertThatThrownBy(() -> hello.success("hello.jsp")) .isInstanceOf(ConfigurationException.class).hasMessageContaining("FreeMarker"); } diff --git a/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java index 5f7b836bc7..593be770f9 100644 --- a/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java @@ -114,7 +114,9 @@ private static String contextPath(String value) { @Test public void testNonNumericPortThrows() { - assertThatThrownBy(() -> BootSettings.load(new Properties(), new String[]{"--struts.boot.port=abc"})) + 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 index c19342c442..7a4b2460cf 100644 --- a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java @@ -152,7 +152,7 @@ public void testHttpMethodsCarried() { } @Test - public void testNoStackMeansPackageDefault() throws Exception { + public void testNoStackMeansPackageDefault() { Configuration configuration = load(usersRegistry()); assertThat(action(configuration, "struts-boot/admin", "users").getInterceptors()).isEmpty(); @@ -246,13 +246,14 @@ public String execute() { @Test public void testClashWithXmlThrows() { - assertThatThrownBy(() -> load(usersRegistry(), "struts-boot-clash.xml")) + ActionRegistry registry = usersRegistry(); + assertThatThrownBy(() -> load(registry, "struts-boot-clash.xml")) .isInstanceOf(ConfigurationException.class) .rootCause().hasMessageContaining("xml-pkg").hasMessageContaining("users"); } @Test - public void testSameNamespaceAsXmlWithDistinctNamesLoads() throws Exception { + public void testSameNamespaceAsXmlWithDistinctNamesLoads() { ActionRegistry registry = new ActionRegistry(); registry.namespace("/admin", ns -> ns.action("list", UserAction.class).method("list").success("admin/users.ftl")); diff --git a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java index 7a8397f259..c3009eb250 100644 --- a/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java +++ b/boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java @@ -38,8 +38,10 @@ 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; @@ -168,7 +170,8 @@ public void testConfigurationErrorStopsTomcatAndCleansBaseDir() throws Exception public void testBusyPortFailsCleanly() throws Exception { Set before = bootTempDirs(); try (ServerSocket busy = new ServerSocket(0)) { - assertThatThrownBy(() -> StrutsBoot.start(HelloApp.class, "--struts.boot.port=" + busy.getLocalPort())) + String portArgument = "--struts.boot.port=" + busy.getLocalPort(); + assertThatThrownBy(() -> StrutsBoot.start(HelloApp.class, portArgument)) .isInstanceOf(ConfigurationException.class); } @@ -199,6 +202,14 @@ public void testCloseDeletesBaseDir() { 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()); } From f95e5ae980b5f9d54eed6d4d678c2ba3efe41dc3 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Sat, 24 Oct 2026 09:37:21 +0200 Subject: [PATCH 15/15] WW-5791 fix(boot): keep the base directory private and close on every start failure The filter and Tomcat are created before the base directory, so nothing can fail between creating the directory and the guarded start block. On file systems without POSIX permissions the base directory is restricted to its owner after creation. Co-Authored-By: Claude Opus 5.5 --- .../apache/struts2/boot/StrutsBootServer.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java index 21bd6c4b6a..d73b21ed70 100644 --- a/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java +++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java @@ -31,6 +31,7 @@ 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; @@ -61,10 +62,10 @@ private StrutsBootServer(Tomcat tomcat, Path baseDir, BootSettings settings) { } static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { - Path baseDir = createBaseDir(); + CapturingStrutsFilter filter = new CapturingStrutsFilter(); Tomcat tomcat = new Tomcat(); + Path baseDir = createBaseDir(); StrutsBootServer server = new StrutsBootServer(tomcat, baseDir, settings); - CapturingStrutsFilter filter = new CapturingStrutsFilter(); try { tomcat.setBaseDir(baseDir.toString()); hideErrorReportDetails(tomcat); @@ -85,10 +86,10 @@ static StrutsBootServer start(ActionRegistry registry, BootSettings settings) { ConfigurationException failure = e instanceof ConfigurationException configurationException ? configurationException : new ConfigurationException("Struts Boot failed to start: " + e.getMessage(), e); - server.closeAfterFailure(failure); + closeAfterFailure(server, failure); throw failure; } catch (Error e) { - server.closeAfterFailure(e); + closeAfterFailure(server, e); throw e; } return server; @@ -126,9 +127,9 @@ public void close() { } } - private void closeAfterFailure(Throwable failure) { + private static void closeAfterFailure(StrutsBootServer server, Throwable failure) { try { - close(); + server.close(); } catch (RuntimeException closeFailure) { failure.addSuppressed(closeFailure); } @@ -202,7 +203,12 @@ private static Path createBaseDir() { if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { return Files.createTempDirectory(prefix, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); } - return Files.createTempDirectory(prefix); + 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); }