Skip to content

Remove Jansi and separate the CLI test tier from the production classpath - #16078

Open
jdaugherty wants to merge 2 commits into
apache:8.0.xfrom
jdaugherty:fix/legacy-jansi
Open

Remove Jansi and separate the CLI test tier from the production classpath#16078
jdaugherty wants to merge 2 commits into
apache:8.0.xfrom
jdaugherty:fix/legacy-jansi

Conversation

@jdaugherty

@jdaugherty jdaugherty commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Description

Removes Jansi from Grails entirely and separates developer tooling from what an application ships.

The PR started as a coordinate swap for CVE-2026-8484 — a heap buffer overflow in Jansi's JNI
ioctl() wrapper, unfixed and unfixable: FuseSource Jansi is unmaintained, every release through
2.4.3 is affected, and no patched version exists.

The obvious remedy was org.jline:jansi, the maintained fork. That turns out to be a false fix. The
fork's native ioctl__IJ_3I is byte-for-byte identical to FuseSource's — same
GetIntArrayElements with no length check before the syscall — differing only in the package inside
the JNI symbol name. The advisory names only the FuseSource coordinate, so migrating would have
silenced the scanner while shipping the same defect. org.jline:jansi:4.3.1 still exposes
public static native int ioctl(int, long, int[]), and JLine has no issue or PR tracking it.

So Jansi is gone outright rather than replaced. The console renders its own escape sequences (a small,
fixed set of standard ones) and JLine remains only as the terminal library it already was.

Reviewing why Jansi was on application classpaths at all exposed the larger problem, and this PR fixes
that too: developer-only tooling was being shipped to every application. Before this change, an
application's production runtime classpath carried grails-console, groovy-console,
groovy-groovysh, groovy-swing and 13 JLine artifacts including Jansi — a Swing console, a
Groovy shell and a terminal stack, none of which an application runs.

Why this change is possible now

8.0.x is a major release branch, where the contribution policy permits breaking API changes. Moving
grails.build.logging.GrailsConsole and its collaborators to a companion artifact is such a change and
could not be made on a patch or minor branch. The CLI companion mechanism (-cli artifacts,
auto-provisioned onto grailsCli) already exists in 8.0.x, so the console moves onto established
infrastructure rather than new machinery.

Issues addressed

This PR closes all five items raised in
this comment:

1. The CLI split needs its own test source set so tests resemble production.
cli output and CLI-only dependencies were being added to the plugin's test classpath, so unit tests
saw classes and libraries an application never receives — a test could pass against something that was
not there in production. Two dedicated source sets now exist, both created automatically by
org.apache.grails.gradle.grails-plugin-cli with nothing to declare:

Source set Purpose
src/testCli Tests for CLI classes; runs as testCli, wired into check
src/integration-test-cli CLI tests needing a booted application; runs as integrationTestCli

src/test no longer sees the CLI tier at all. The direction is deliberately one-way: CLI tests may use
test fixtures, ordinary tests never see CLI classes. Roughly 70 existing specs moved accordingly.

2. CLI-specific classes moved to the CLI source set.
AnsiConsoleUrlMappingsRenderer moved to grails-web-url-mappings's src/cli — its only consumer was
already the URL-mappings report command. An audit of every module's main sources for GrailsConsole and
org.jline references found one further cluster: the console itself. Nine classes moved from
grails-bootstrap to the new grails-bootstrap-cli companion. The app-facing grails-bootstrap jar
now contains 81 classes and no console classes.

ArtefactTypeAstTransformation was the last main-source reference; it called
GrailsConsole.getInstance().error(...) on an AST-injector error path, wrapped in
catch (Throwable) { ignore } solely because the console could throw NoClassDefFoundError. It runs
inside the Groovy compiler and now uses a logger.

3. JLine/Jansi eliminated from the production classpath — proven and tested.
Proven by measurement and then enforced, so it cannot regress. A new validateProductionClasspath task
resolves runtimeClasspath and fails on any forbidden coordinate; it is wired into check and
configured on grails-dependencies/starter-web. It was negative-tested by deliberately reinstating
grails-console and confirming the failure lists every offending artifact.

4. Jansi removed entirely rather than swapped.
No third-party ANSI library remains. ConsoleAnsi (~130 lines, internal) emits the same standard
sequences, buffering style changes so consecutive ones collapse into a single SGR exactly as before.
Output is byte-identical: ESC[1;31m| LABEL ESC[22;39mMSG ESC[m for error, and
ESC[1A ESC[1D ESC[0K ESC[1;33m| ESC[22;39mMSG ESC[m for outputMessage. Emitting directly rather than
via JLine's AttributedStringBuilder was deliberate — that would have regrouped the SGR runs and made
cursor control depend on the terminfo database, which Jansi never did.

5. grails-console is now CLI-only.
It was exposed by grails-dependencies/starter-web, which put it on every application's runtime
classpath. It was already auto-provisioned onto grailsCli by the Grails Gradle plugin, so the starter
entry was both redundant and the cause. Removed. The console and shell tasks are unaffected — they
source it from grailsCli, which never reaches runtimeClasspath, so bootRun, bootJar and bootWar
are clean.

Also found and fixed

  • GrailsConsoleSpec had never executed — anywhere. It was gated on
    @IgnoreIf({ !GrailsConsole.instance.isAnsiEnabled() }), and a Gradle test worker has no tty, so the
    terminal is always dumb and the gate always skipped. Its own fixture also built a dumb terminal, so
    it would have emitted no ANSI even if it had run. It now drives an ExternalTerminal and runs 10
    assertions covering colour, cursor movement, reset and the disabled path. Un-gating it immediately
    surfaced two latent hazards it had been hiding.
  • ANSI configuration now uses Spring Boot's property. Removing Jansi took its global switch with it.
    Rather than invent a Grails equivalent, GrailsConsole honours spring.output.ansi.enabled
    (always/detect/never), preferring what Boot bound to AnsiOutput and falling back to the system
    property for the CLI, which has no Environment. always is a new capability — Jansi's switch could
    only disable.
  • withJansi removed from 26 Logback configurations. Logback's ConsoleAppender loads
    org.fusesource.jansi.AnsiConsole by name, so with Jansi gone it logged a ClassNotFoundException
    warning on every startup and fell back to the plain stream. Nothing is lost: colour comes from Spring
    Boot's ColorConverter (%clr), which honours the same property. Generated applications already omit
    it (<withJansi>true</withJansi> breaks console logging after first reload in Grails 7.1.0 #15663 — Jansi's global System.out replacement breaks console logging after a DevTools restart);
    only configurations carried forward were affected.
  • SBOM licence override. jline.version moved to 3.30.9 to match the JLine version Groovy ships,
    which orphaned the version-pinned licence override and failed cyclonedxDirectBom. CycloneDX maps
    JLine's "The BSD License" to BSD-4-Clause (License Mapping for BSD-3-Clause and BSD-4-Clause CycloneDX/cyclonedx-core-java#205); the POMs declare
    BSD-3-Clause. Override updated.
  • CLI test results were invisible. testCli/integrationTestCli ran under check but were absent
    from the aggregate reports, hiding ~69 suites. Added to grails-test-report's phases.

Verification

  • Full gate: ./gradlew clean aggregateViolations :grails-test-report:check --continue
  • Checkstyle, CodeNarc, PMD and SpotBugs aggregate reports: no violations
  • 12,025 unit tests, 0 failures; 3,618 integration tests, 0 failures
  • 67 testCli and 2 integrationTestCli suites present in the aggregate reports
  • End-to-end suite against published artifacts: 7/7 LegacyCommandCompatibilityIntegrationSpec
    passing, with the Grails 7 fixture verified as built by the Grails 7 toolchain (Grails 7.0.14,
    Groovy 4.0.32, JDK 17)
  • Legacy application's production runtimeClasspath confirmed free of JLine, Jansi, grails-bootstrap-cli
    and grails-console; its integration test classpath correctly resolves grails-bootstrap-cli from
    published metadata with no capability conflict

Contributor Checklist

Issue and Scope

  • This PR is linked to an existing issue that has been acknowledged or approved by the project team. If no approved issue exists, please give background on why this change is necessary. Tickets are preferred for release change log history.
  • This PR addresses the complete scope of the linked issue. Partial implementations or unfinished work should not be submitted for review.
  • This PR contains a single, focused change. Unrelated changes should be submitted as separate pull requests.
  • This PR targets the correct branch for the type of change:
    • Patch release branches (e.g., 7.0.x): Bug fixes only. No new features or API changes.
    • Minor release branches (e.g., 7.1.x): New features are welcome, but breaking existing APIs must be avoided.
    • Major release branches (e.g., 8.0.x): Reserved for major changes. Breaking API changes are permitted.

Code Quality

  • I have added or updated tests that cover the changes introduced in this PR. All code contributions are expected to include appropriate test coverage.
  • I have verified that all existing tests pass by running ./gradlew build --rerun-tasks.
  • My code follows the project's code style guidelines. I have run ./gradlew codeStyle and resolved any violations. See Code Style for details.
  • This PR does not include mass reformatting, style-only changes, or large-scale refactoring unless it was explicitly approved in the linked issue. Unsolicited reformatting will not be accepted.
  • If generative AI tooling was used in preparing this contribution, a quality model was used to ensure contributions are consistent with the project's quality standards.

Licensing and Attribution

Documentation

  • If this PR introduces user-facing changes, I have included or updated the relevant documentation.
  • If this PR adds a new feature, I have updated the What's New section of the Grails Guide.
  • If this PR introduces breaking changes or changes that require user action during an upgrade, I have updated the Upgrade Notes for the corresponding version in the Grails Guide.
  • The PR description clearly explains what was changed and why.

Upgrade notes cover: removing runtimeOnly "org.fusesource.jansi:jansi"; the
spring.output.ansi.enabled property with a before/after mapping from Jansi's own switch; removing
withJansi from Logback configuration; the move of GrailsConsole and its collaborators to
grails-bootstrap-cli; and the new CLI test source sets for plugin authors.

Generative AI tooling was used to assist with scan-log analysis, CVE research, dependency resolution
verification, and PR preparation. The resulting changes were reviewed and verified by the submitter.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

This PR is attempting to adopt org.jline's fork, but additional issues found as part of this work:

  1. the cli split needs it's own test source set split so tests have a similar classpath to production
  2. any CLI specific class (i.e. AnsiConsoleUrlMappingsRenderer) needs moved to the CLI source set
  3. jline/jansi needs eliminated completely from the production classpath as it's likely only used for the console / shell (this needs proven / tested)
  4. It's very likely jansi can be entirely removed now since adopting the jline specific makes it a dependency of jline and we don't need a third party library
  5. the grails-console probably should be a cli only library and not on the production classpath

@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley I have proposed we hold the RC1 release until we can work through the issues in this comment

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 2.85714% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.9220%. Comparing base (57883c4) to head (a2406ab).
⚠️ Report is 5 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ugin/commands/GrailsCliArtifactGradlePlugin.groovy 0.0000% 33 Missing ⚠️
...piler/injection/ArtefactTypeAstTransformation.java 50.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             8.0.x     #16078         +/-   ##
================================================
+ Coverage         0   51.9220%   +51.9220%     
- Complexity       0      18013      +18013     
================================================
  Files            0       2038       +2038     
  Lines            0      95680      +95680     
  Branches         0      16641      +16641     
================================================
+ Hits             0      49679      +49679     
- Misses           0      38671      +38671     
- Partials         0       7330       +7330     
Files with missing lines Coverage Δ
...y/org/grails/test/io/SystemOutAndErrSwapper.groovy 0.0000% <ø> (ø)
...piler/injection/ArtefactTypeAstTransformation.java 70.4546% <50.0000%> (ø)
...ugin/commands/GrailsCliArtifactGradlePlugin.groovy 0.0000% <0.0000%> (ø)

... and 2035 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

I intend to push a change that tries to adjust the issues mentioned in the previous comments.

…s as separate from the production classpath, and remove the need for the legacy jansi library
@jdaugherty jdaugherty changed the title Replace fusesource jansi with org.jline's fork Remove Jansi and separate the CLI tier from the production classpath Aug 2, 2026
@jdaugherty
jdaugherty marked this pull request as ready for review August 2, 2026 03:47
@jdaugherty jdaugherty changed the title Remove Jansi and separate the CLI tier from the production classpath Remove Jansi and separate the CLI test tier from the production classpath Aug 2, 2026
@jdaugherty

Copy link
Copy Markdown
Contributor Author

The biggest reason we have to do this change is we had 2 versions of jansi on our dependency list - groovy ships with one version, and then we had the old, unmaintained one. This makes us adopt the jline version groovy uses and it ensures we don't have a CVE in our production classpath.

@testlens-app

testlens-app Bot commented Aug 2, 2026

Copy link
Copy Markdown

🚨 TestLens detected 2 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

CI - Groovy Joint Validation Build / build_grails > :grails-data-hibernate5-dbmigration:testCli

Test Runs Flakiness
GroovyChangeLogSpec > outputs a warning message by calling the warn method 0% 🟢
GroovyChangeLogSpec > updates a database with Groovy Change 0% 🟢

🏷️ Commit: a2406ab
▶️ Tests: 66030 executed
⚪️ Checks: 62/62 completed

Test Failures

GroovyChangeLogSpec > outputs a warning message by calling the warn method (:grails-data-hibernate5-dbmigration:testCli in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('warn message')
|      |          |
|      |          false
|      04:14:19.471 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
|      Running Changeset: changelog.groovy::2::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
04:14:19.471 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Running Changeset: changelog.groovy::2::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.outputs a warning message by calling the warn method(GroovyChangeLogSpec.groovy:87)
GroovyChangeLogSpec > updates a database with Groovy Change (:grails-data-hibernate5-dbmigration:testCli in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('confirmation message')
|      |          |
|      |          false
|      04:14:18.147 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
|      Running Changeset: changelog.groovy::1::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
04:14:18.147 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Running Changeset: changelog.groovy::1::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.updates a database with Groovy Change(GroovyChangeLogSpec.groovy:61)

Muted Tests

Select tests to mute in this pull request:

  • GroovyChangeLogSpec > outputs a warning message by calling the warn method
  • GroovyChangeLogSpec > updates a database with Groovy Change

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant