Merge pull request #4001 from eclipse/jetty-9.4.x-4000-swedish-unicode-file-serving

Issue #4000 - new SameFileAliasChecker to help with NFC/NFD UTF-8 differences
This commit is contained in:
Joakim Erdfelt 2019-08-23 07:27:32 -05:00 committed by GitHub
commit 1867d24ef7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 288 additions and 61 deletions

View File

@ -0,0 +1,78 @@
//
// ========================================================================
// Copyright (c) 1995-2019 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.server;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.eclipse.jetty.server.handler.ContextHandler.AliasCheck;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.resource.PathResource;
import org.eclipse.jetty.util.resource.Resource;
/**
* Alias checking for working with FileSystems that normalize access to the
* File System.
* <p>
* The Java {@link Files#isSameFile(Path, Path)} method is used to determine
* if the requested file is the same as the alias file.
* </p>
* <p>
* For File Systems that are case insensitive (eg: Microsoft Windows FAT32 and NTFS),
* the access to the file can be in any combination or style of upper and lowercase.
* </p>
* <p>
* For File Systems that normalize UTF-8 access (eg: Mac OSX on HFS+ or APFS,
* or Linux on XFS) the the actual file could be stored using UTF-16,
* but be accessed using NFD UTF-8 or NFC UTF-8 for the same file.
* </p>
*/
public class SameFileAliasChecker implements AliasCheck
{
private static final Logger LOG = Log.getLogger(SameFileAliasChecker.class);
@Override
public boolean check(String uri, Resource resource)
{
// Only support PathResource alias checking
if (!(resource instanceof PathResource))
return false;
try
{
PathResource pathResource = (PathResource)resource;
Path path = pathResource.getPath();
Path alias = pathResource.getAliasPath();
if (Files.isSameFile(path, alias))
{
if (LOG.isDebugEnabled())
LOG.debug("Allow alias to same file {} --> {}", path, alias);
return true;
}
}
catch (IOException e)
{
LOG.ignore(e);
}
return false;
}
}

View File

@ -52,7 +52,7 @@ public class AllowSymLinkAliasChecker implements AliasCheck
Path path = pathResource.getPath();
Path alias = pathResource.getAliasPath();
if (path.equals(alias))
if (PathResource.isSameName(alias, path))
return false; // Unknown why this is an alias
if (hasSymbolicLink(path) && Files.isSameFile(path, alias))

View File

@ -54,12 +54,14 @@ import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.LocalConnector;
import org.eclipse.jetty.server.ResourceContentFactory;
import org.eclipse.jetty.server.ResourceService;
import org.eclipse.jetty.server.SameFileAliasChecker;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AllowSymLinkAliasChecker;
import org.eclipse.jetty.toolchain.test.FS;
import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.toolchain.test.jupiter.WorkDir;
import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension;
import org.eclipse.jetty.util.TypeUtil;
import org.eclipse.jetty.util.log.StacklessLogging;
import org.eclipse.jetty.util.resource.PathResource;
import org.eclipse.jetty.util.resource.Resource;
@ -73,6 +75,7 @@ import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jetty.http.HttpFieldsMatchers.containsHeader;
import static org.eclipse.jetty.http.HttpFieldsMatchers.containsHeaderValue;
import static org.hamcrest.MatcherAssert.assertThat;
@ -2009,6 +2012,76 @@ public class DefaultServletTest
response = HttpTester.parseResponse(rawResponse);
assertThat(response.toString(), response.getStatus(), is(HttpStatus.PRECONDITION_FAILED_412));
}
@Test
public void testGetUtf8NfcFile() throws Exception
{
FS.ensureEmpty(docRoot);
context.addServlet(DefaultServlet.class, "/");
context.addAliasCheck(new SameFileAliasChecker());
// UTF-8 NFC format
String filename = "swedish-" + new String(TypeUtil.fromHexString("C3A5"), UTF_8) + ".txt";
createFile(docRoot.resolve(filename), "hi a-with-circle");
String rawResponse;
HttpTester.Response response;
// Request as UTF-8 NFC
rawResponse = connector.getResponse("GET /context/swedish-%C3%A5.txt HTTP/1.1\r\nHost:test\r\nConnection:close\r\n\r\n");
response = HttpTester.parseResponse(rawResponse);
assertThat(response.getStatus(), is(HttpStatus.OK_200));
assertThat(response.getContent(), is("hi a-with-circle"));
// Request as UTF-8 NFD
rawResponse = connector.getResponse("GET /context/swedish-a%CC%8A.txt HTTP/1.1\r\nHost:test\r\nConnection:close\r\n\r\n");
response = HttpTester.parseResponse(rawResponse);
if (OS.MAC.isCurrentOs())
{
assertThat(response.getStatus(), is(HttpStatus.OK_200));
assertThat(response.getContent(), is("hi a-with-circle"));
}
else
{
assertThat(response.getStatus(), is(HttpStatus.NOT_FOUND_404));
}
}
@Test
public void testGetUtf8NfdFile() throws Exception
{
FS.ensureEmpty(docRoot);
context.addServlet(DefaultServlet.class, "/");
context.addAliasCheck(new SameFileAliasChecker());
// UTF-8 NFD format
String filename = "swedish-" + new String(TypeUtil.fromHexString("61CC8A"), UTF_8) + ".txt";
createFile(docRoot.resolve(filename), "hi a-with-circle");
String rawResponse;
HttpTester.Response response;
// Request as UTF-8 NFD
rawResponse = connector.getResponse("GET /context/swedish-a%CC%8A.txt HTTP/1.1\r\nHost:test\r\nConnection:close\r\n\r\n");
response = HttpTester.parseResponse(rawResponse);
assertThat(response.getStatus(), is(HttpStatus.OK_200));
assertThat(response.getContent(), is("hi a-with-circle"));
// Request as UTF-8 NFC
rawResponse = connector.getResponse("GET /context/swedish-%C3%A5.txt HTTP/1.1\r\nHost:test\r\nConnection:close\r\n\r\n");
response = HttpTester.parseResponse(rawResponse);
if (OS.MAC.isCurrentOs())
{
assertThat(response.getStatus(), is(HttpStatus.OK_200));
assertThat(response.getContent(), is("hi a-with-circle"));
}
else
{
assertThat(response.getStatus(), is(HttpStatus.NOT_FOUND_404));
}
}
public static class OutputFilter implements Filter
{

View File

@ -104,56 +104,10 @@ public class PathResource extends Resource
{
Path real = abs.toRealPath(FOLLOW_LINKS);
/*
* If the real path is not the same as the absolute path
* then we know that the real path is the alias for the
* provided path.
*
* For OS's that are case insensitive, this should
* return the real (on-disk / case correct) version
* of the path.
*
* We have to be careful on Windows and OSX.
*
* Assume we have the following scenario
* Path a = new File("foo").toPath();
* Files.createFile(a);
* Path b = new File("FOO").toPath();
*
* There now exists a file called "foo" on disk.
* Using Windows or OSX, with a Path reference of
* "FOO", "Foo", "fOO", etc.. means the following
*
* | OSX | Windows | Linux
* -----------------------+---------+------------+---------
* Files.exists(a) | True | True | True
* Files.exists(b) | True | True | False
* Files.isSameFile(a,b) | True | True | False
* a.equals(b) | False | True | False
*
* See the javadoc for Path.equals() for details about this FileSystem
* behavior difference
*
* We also cannot rely on a.compareTo(b) as this is roughly equivalent
* in implementation to a.equals(b)
*/
int absCount = abs.getNameCount();
int realCount = real.getNameCount();
if (absCount != realCount)
if (!isSameName(abs, real))
{
// different number of segments
return real;
}
// compare each segment of path, backwards
for (int i = realCount - 1; i >= 0; i--)
{
if (!abs.getName(i).toString().equals(real.getName(i).toString()))
{
return real;
}
}
}
}
catch (IOException e)
@ -167,6 +121,82 @@ public class PathResource extends Resource
return null;
}
/**
* Test if the paths are the same name.
*
* <p>
* If the real path is not the same as the absolute path
* then we know that the real path is the alias for the
* provided path.
* </p>
*
* <p>
* For OS's that are case insensitive, this should
* return the real (on-disk / case correct) version
* of the path.
* </p>
*
* <p>
* We have to be careful on Windows and OSX.
* </p>
*
* <p>
* Assume we have the following scenario:
* </p>
*
* <pre>
* Path a = new File("foo").toPath();
* Files.createFile(a);
* Path b = new File("FOO").toPath();
* </pre>
*
* <p>
* There now exists a file called {@code foo} on disk.
* Using Windows or OSX, with a Path reference of
* {@code FOO}, {@code Foo}, {@code fOO}, etc.. means the following
* </p>
*
* <pre>
* | OSX | Windows | Linux
* -----------------------+---------+------------+---------
* Files.exists(a) | True | True | True
* Files.exists(b) | True | True | False
* Files.isSameFile(a,b) | True | True | False
* a.equals(b) | False | True | False
* </pre>
*
* <p>
* See the javadoc for Path.equals() for details about this FileSystem
* behavior difference
* </p>
*
* <p>
* We also cannot rely on a.compareTo(b) as this is roughly equivalent
* in implementation to a.equals(b)
* </p>
*/
public static boolean isSameName(Path pathA, Path pathB)
{
int aCount = pathA.getNameCount();
int bCount = pathB.getNameCount();
if (aCount != bCount)
{
// different number of segments
return false;
}
// compare each segment of path, backwards
for (int i = bCount; i-- > 0; )
{
if (!pathA.getName(i).toString().equals(pathB.getName(i).toString()))
{
return false;
}
}
return true;
}
/**
* Construct a new PathResource from a File object.
* <p>

View File

@ -19,6 +19,7 @@
package org.eclipse.jetty.util.resource;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@ -318,7 +319,43 @@ public class FileSystemResourceTest
}
}
}
@ParameterizedTest
@MethodSource("fsResourceProvider")
public void testAccessUniCodeFile(Class resourceClass) throws Exception
{
Path dir = workDir.getEmptyPathDir();
String readableRootDir = findRootDir(dir.getFileSystem());
assumeTrue(readableRootDir != null, "Readable Root Dir found");
Path subdir = dir.resolve("sub");
Files.createDirectories(subdir);
touchFile(subdir.resolve("swedish-å.txt"), "hi a-with-circle");
touchFile(subdir.resolve("swedish-ä.txt"), "hi a-with-two-dots");
touchFile(subdir.resolve("swedish-ö.txt"), "hi o-with-two-dots");
try (Resource base = newResource(resourceClass, subdir.toFile()))
{
Resource refA1 = base.addPath("swedish-å.txt");
Resource refA2 = base.addPath("swedish-ä.txt");
Resource refO1 = base.addPath("swedish-ö.txt");
assertThat("Ref A1 exists", refA1.exists(), is(true));
assertThat("Ref A2 exists", refA2.exists(), is(true));
assertThat("Ref O1 exists", refO1.exists(), is(true));
assertThat("Ref A1 alias", refA1.isAlias(), is(false));
assertThat("Ref A2 alias", refA2.isAlias(), is(false));
assertThat("Ref O1 alias", refO1.isAlias(), is(false));
assertThat("Ref A1 contents", toString(refA1), is("hi a-with-circle"));
assertThat("Ref A2 contents", toString(refA2), is("hi a-with-two-dots"));
assertThat("Ref O1 contents", toString(refO1), is("hi o-with-two-dots"));
}
}
private String findRootDir(FileSystem fs) throws IOException
{
// look for a directory off of a root path
@ -419,12 +456,7 @@ public class FileSystemResourceTest
Files.createDirectories(dir);
Path file = dir.resolve("foo");
try (StringReader reader = new StringReader("foo");
BufferedWriter writer = Files.newBufferedWriter(file))
{
IO.copy(reader, writer);
}
touchFile(file, "foo");
long expected = Files.size(file);
@ -513,12 +545,7 @@ public class FileSystemResourceTest
Path file = dir.resolve("foo");
String content = "Foo is here";
try (StringReader reader = new StringReader(content);
BufferedWriter writer = Files.newBufferedWriter(file))
{
IO.copy(reader, writer);
}
touchFile(file, content);
try (Resource base = newResource(resourceClass, dir.toFile()))
{
@ -1507,4 +1534,23 @@ public class FileSystemResourceTest
assertThat("getAlias()", resource.getAlias(), nullValue());
}
}
private String toString(Resource resource) throws IOException
{
try (InputStream inputStream = resource.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream())
{
IO.copy(inputStream, outputStream);
return outputStream.toString("utf-8");
}
}
private void touchFile(Path outputFile, String content) throws IOException
{
try (StringReader reader = new StringReader(content);
BufferedWriter writer = Files.newBufferedWriter(outputFile))
{
IO.copy(reader, writer);
}
}
}