jetty / jetty.project

Eclipse Jetty® - Web Container & Clients - supports HTTP/2, HTTP/1.1, HTTP/1.0, websocket, servlets, and more

Home Page:https://eclipse.dev/jetty

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "etag" is null

optyfr opened this issue · comments

Jetty version(s)
12.0.10

Jetty Environment
ee9

Java version/vendor (use: java -version)
openjdk version "21.0.2" 2024-01-16 LTS
OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode, sharing)

OS type/version
Windows 11 and Linux Debian bookworm (12)

Description
22:46:12.082 [main] INFO org.eclipse.jetty.server.Server - jetty-12.0.8; built: 2024-03-29T19:58:19.443Z; git: ffffdcc; jvm 21.0.2+13-LTS
22:46:12.122 [main] INFO o.e.j.s.DefaultSessionIdManager - Session workerName=node0
22:46:12.132 [main] INFO o.e.j.server.handler.ContextHandler - Started oeje9n.ContextHandler$CoreContextHandler@23941fb4{ROOT,/,b=URLResource@12C8CFCA(jar:file:/D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar!/webclient/),a=AVAILABLE,h=oeje9n.ContextHandler$CoreContextHandler$CoreToNestedHandler@5d908d47{STARTED}}
22:46:12.195 [main] INFO o.e.jetty.server.AbstractConnector - Started HTTP@452e19ca{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}
22:46:12.215 [main] INFO org.eclipse.jetty.server.Server - Started oejs.Server@3527942a{STARTING}[12.0.8,sto=0] @642ms
Enter 'stop' to halt:
22:46:17.755 [qtp664792509-41] WARN o.e.jetty.ee9.nested.HttpChannel - /smartgwt/sc/modules/ISC_Core.js
java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "etag" is null
at org.eclipse.jetty.http.EtagUtils.rewriteWithSuffix(EtagUtils.java:217)
at org.eclipse.jetty.http.content.PreCompressedHttpContent.(PreCompressedHttpContent.java:46)
at org.eclipse.jetty.ee9.nested.ResourceService.doGet(ResourceService.java:316)
at org.eclipse.jetty.ee9.servlet.DefaultServlet.doGet(DefaultServlet.java:506)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:500)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:587)
at org.eclipse.jetty.ee9.servlet.ServletHolder.handle(ServletHolder.java:765)
at org.eclipse.jetty.ee9.servlet.ServletHandler.doHandle(ServletHandler.java:528)
at org.eclipse.jetty.ee9.nested.ScopedHandler.nextHandle(ScopedHandler.java:195)
at org.eclipse.jetty.ee9.nested.SessionHandler.doHandle(SessionHandler.java:476)
at org.eclipse.jetty.ee9.nested.ScopedHandler.nextHandle(ScopedHandler.java:195)
at org.eclipse.jetty.ee9.nested.ContextHandler.doHandle(ContextHandler.java:1034)
at org.eclipse.jetty.ee9.nested.ScopedHandler.nextScope(ScopedHandler.java:164)
at org.eclipse.jetty.ee9.servlet.ServletHandler.doScope(ServletHandler.java:483)
at org.eclipse.jetty.ee9.nested.ScopedHandler.nextScope(ScopedHandler.java:162)
at org.eclipse.jetty.ee9.nested.SessionHandler.doScope(SessionHandler.java:470)
at org.eclipse.jetty.ee9.nested.ScopedHandler.nextScope(ScopedHandler.java:162)
at org.eclipse.jetty.ee9.nested.ContextHandler.doScope(ContextHandler.java:955)
at org.eclipse.jetty.ee9.nested.ScopedHandler.handle(ScopedHandler.java:125)
at org.eclipse.jetty.ee9.nested.ContextHandler.handle(ContextHandler.java:1693)
at org.eclipse.jetty.ee9.nested.HttpChannel$RequestDispatchable.dispatch(HttpChannel.java:1575)
at org.eclipse.jetty.ee9.nested.HttpChannel.dispatch(HttpChannel.java:737)
at org.eclipse.jetty.ee9.nested.HttpChannel.handle(HttpChannel.java:510)
at org.eclipse.jetty.ee9.nested.ContextHandler$CoreContextHandler$CoreToNestedHandler.handle(ContextHandler.java:2727)
at org.eclipse.jetty.server.handler.ContextHandler.handle(ContextHandler.java:851)
at org.eclipse.jetty.server.handler.gzip.GzipHandler.handle(GzipHandler.java:597)
at org.eclipse.jetty.server.Server.handle(Server.java:179)
at org.eclipse.jetty.server.internal.HttpChannelState$HandlerInvoker.run(HttpChannelState.java:619)
at org.eclipse.jetty.server.internal.HttpConnection.onFillable(HttpConnection.java:411)
at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:322)
at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:99)
at org.eclipse.jetty.io.SelectableChannelEndPoint$1.run(SelectableChannelEndPoint.java:53)
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.runTask(AdaptiveExecutionStrategy.java:478)
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.consumeTask(AdaptiveExecutionStrategy.java:441)
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.tryProduce(AdaptiveExecutionStrategy.java:293)
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.produce(AdaptiveExecutionStrategy.java:195)
at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:979)
at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.doRunJob(QueuedThreadPool.java:1209)
at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.run(QueuedThreadPool.java:1164)
at java.base/java.lang.Thread.run(Thread.java:1583)

How to reproduce?
happens when serving resources with DefaultServlet and it's precompressed gzip content inside a jar archive as URLResource... My understanding is that jetty is currently unable to provide an etag from httpcontent with that use case

The NPE needs to be fixed, but it's caused by bad assumptions in your implementation.

Looking at your implementation and it's use of DefaultServlet you are abusing it in various ways.

https://github.com/optyfr/JRomManager/blob/master/jrmserver/src/main/java/jrm/fullserver/FullServer.java#L374-L383

The DefaultServlet on Jetty 6 thru Jetty 12 can handle servlet url-patterns of / (the default pattern), and prefix patterns (eg: /static/*) with the pathInfoOnly init-param set to True (see Jetty 10 - ServletFileServerMultipleLocations.java on jetty-examples).
That's it. Nothing else.
No support for absolute url-patterns (a pattern without a glob, eg /index.js).
No support for suffix based url-patterns (eg: *.js).

The only reason you seem to have this setup is an attempt to configure the cache-control response headers.
You should either have a custom Filter that applies it or use the Rewrite Handler on responses to control that, not via DefaultServlet configuration. (I would recommend a Filter).

URLResource@12C8CFCA(jar:file:/D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar!/webclient/

Why are you using a URLResource?
Knowing that URL and all of the URL stream mappings have undergone changes and deprecations back around JDK 20, and your docker image is on JDK 21, you should no longer be using URLResource.
The JDK zipfs is the preferred mechanism for accessing compressed contents with modern JDKs.

Looking at https://github.com/optyfr/JRomManager/blob/master/jrmserver/src/main/java/jrm/fullserver/FullServer.java#L203

You have this wrong ..

  1. you are not managing the Jetty lifecycle of those resource factories.
  2. you are not verifying that the Resource exists before using it (the return from newResource can easily be null in your codebase)

To obtain a ResourceFactory it should be bound to a component in Jetty that has a LifeCycle (such as a WebAppContext or ServletContextHandler).
To verify the return of newResouce() (any param type) or newClassLoaderResource(), use the import org.eclipse.jetty.util.resource.Resources statics.

Like this.

ResourceFactory resourceFactory = ResourceFactory.of(context);

As for the code you have as ...

	protected static Resource getClientPath(String path) throws URISyntaxException
	{
		if (path != null)
			return prf.newResource(getPath(path));
		final var p = getPath("jrt:/jrm.merged.module/webclient/");
		if (Files.exists(p))
			return prf.newResource(p);
		return urf.newResource(FullServer.class.getResource("/webclient/"));
	}

That should read like ...

    protected static Resource getClientPath(ResourceFactory resourceFactory, String path) throws IOException
    {
        Resource resource;

        if (path != null)
        {
            resource = resourceFactory.newResource(path);
            if (Resources.exists(resource))
                return resource;
            resource = resourceFactory.newClassLoaderResource(path, true);
            if (Resources.exists(resource))
                return resource;
        }

        resource = resourceFactory.newResource("jrt:/jrm.merged.module/webclient/");
        if (Resources.exists(resource))
            return resource;
        URL url = FullServer.class.getResource("/webclient/");
        if (url != null)
        {
            resource = resourceFactory.newResource(url);
            if (Resources.exists(resource))
                return resource;
        }
        throw new FileNotFoundException("Unable to find webclient path");
    }

This uses only 1 ResourceFactory, the one tied to the LifeCycle of the context.
This also uses the various ResourceFactory.newResource() and ResourceFactory.newClassLoaderResource().
It eliminates entirely the whole getPath() logic which doesn't gain you anything useful.
It also uses org.eclipse.jetty.util.resource.Resources to verify that a resource can even be created and accessed.

Thanks for the indications @joakime
I first replaced with filters and kept only one DefaultServlet for / but it still gave the NPE as soon I did hit a precompressed file but it still worked ok in other use cases (jrt: and file: on folder)
Then I replaced my getClientPath with the one you wrote depending on the ServletContext and using only 1 resourceFactory...
and unfortunately despite it returns a valid Resource for the jar file it now give a 404 for any request for that jar!

maybe related I noticed the warning message when launched :

o.e.j.server.handler.ContextHandler - Base Resource should not be an alias
But I don't understand why it says that it as an alias

Here is the full output when launched :

$ java -cp JRomManager.jar jrm.server.Server --debug
21:23:26.599 [main] INFO  org.eclipse.jetty.server.Server - jetty-12.0.10; built: 2024-05-30T04:40:36.563Z; git: 26106dfc84a03ddb6216062fe33b047fc332d0ce; jvm 21.0.2+13-LTS
21:23:26.611 [main] WARN  o.e.j.server.handler.ContextHandler - Base Resource should not be an alias
21:23:26.630 [main] INFO  o.e.j.s.DefaultSessionIdManager - Session workerName=node0
21:23:26.639 [main] INFO  o.e.j.server.handler.ContextHandler - Started oeje9n.ContextHandler$CoreContextHandler@5852c06f{ROOT,/,b=jar:file:/D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar!/webclient/,a=AVAILABLE,h=oeje9n.ContextHandler$CoreContextHandler$CoreToNestedHandler@4149c063{STARTED}}
21:23:26.684 [main] INFO  o.e.jetty.server.AbstractConnector - Started HTTP@bb9e6dc{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}
21:23:26.698 [main] INFO  org.eclipse.jetty.server.Server - Started oejs.Server@543295b0{STARTING}[12.0.10,sto=0] @584ms
[2024-06-17 21:23:26] [CONFIG] Start server
[2024-06-17 21:23:26] [CONFIG] HTTP with port on 8080 binded to 0.0.0.0
[2024-06-17 21:23:26] [CONFIG] clientPath: jar:file:/D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar!/webclient/
[2024-06-17 21:23:26] [CONFIG] workPath: D:\git\JRomManager\build\install\JRomManager-nogui-noarch
Enter 'stop' to halt:

to get the resourceFactory I did like this :

final var context = new ServletContextHandler(ServletContextHandler.SESSIONS);
final var  resourceFactory = ResourceFactory.of(context);
context.setBaseResource(getClientPath(resourceFactory, clientPath));
context.setContextPath("/");

I also tried with ResourceFactory.root(); with no better success

I will commit what I modified, if you want to look at what I did (I only modified jrm.server.Server, not the FullServer)

o.e.j.server.handler.ContextHandler - Base Resource should not be an alias
But I don't understand why it says that it as an alias

The path on a Base Resource MUST be the correct path as it is stored on disk.
The fact that Windows paths are case insensitive just makes things on the web extra complicated.

I see several different cases in your output (the / vs \ differences are irrelevant on Java, only the individual path segments)

D:\git\JRomManager\build\install\JRomManager-nogui-noarch
D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar

That first directory entry git vs GIT stands out to me.

One case is an alias to the other as-stored case.

The case differences usually comes from a configuration file somewhere, where the string is being used as-is (without a real-path lookup / resolution / normalization / de-symlinking / etc) with Jetty's newResource(String).

You can fix this by always doing Path.toRealPath() before passing the value into Jetty.
But that method only applies to file system paths, URLs are a different case (esp ones that point to file: or jar: or jrt: schemes)

Once PR #11927 is merged, then I have a few things to do for this issue.

  • Add testcase to replicate NPE (using URLResource)
  • Fix NPE (I have a good idea where to do that)
  • Add more details to logging output of o.e.j.server.handler.ContextHandler - Base Resource should not be an alias to help troubleshoot that warning in the future.

I managed to get it working by doing this...

URL url = FullServer.class.getResource("/webclient/");
if (url != null)
{
	if(url.toURI().getScheme().equalsIgnoreCase("jar"))
		resource = resourceFactory.newResource(FileSystems.newFileSystem(url.toURI(), Map.of()).getPath("/webclient"));
	else
		resource = resourceFactory.newResource(url);
    if (Resources.exists(resource))
        return resource;
}

That's a bit ugly but it works
what's the difference with the two URI ?
Class.getResource is returning

jar:file:/D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar!/webclient/

then FileSystems.getPath will return

jar:file:///D:/GIT/JRomManager/build/install/JRomManager-nogui-noarch/lib/jrm-WebClient-3.0.0.jar!/webclient/

jetty seems to be happier with the later because of jar:file:///

FullServer.class.getResource("/webclient/")

I would expect that to return the URL based on your classpath.
If your classpath entry is using incorrect case, your resulting URL here would also be using incorrect case.

jetty seems to be happier with the later because of jar:file:///

You are finding one of the oddities of java's URL class.
URL will mangle the file: scheme and use only 1 /, which causes problems with everything else that isn't Java's URL class.
This isn't unique to nested schemes jar:file: but also just a normal file: scheme too.

This isn't the only bug in the Java URL class.
Do yourself a favor and avoid Java URL as much as humanly possible.

Jetty has a utility built-in to correct that syntax btw (and it is also Windows UNC URI/URL aware)

URI uri = org.eclipse.jetty.util.URIUtil.correctURI(url.toUri())
Resource resource = resourceFactory.newResource(uri);

the case was correct for the class path, the "should not be an alias" message is only because of the missing //

I did found in between that correctURI utility method

So finally the NPE seems to be linked with the use of a separate URLResourceFactory, as soon as I used the more generic ResourceFactory (with a fixed uri), all started to work ok

Thank you

should I keep the issue open for reference or close it?

Opened PR #11930 to address the various concerns brought up here.

Merged PR #11930 to address the NPE in Etags with URLResource, and also make warning "Base Resource should not be an alias" more clear.