From 403e0666f37b60b53525a647f2ffdc4673d70f2d Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Mon, 3 Jul 2023 10:50:12 -0500 Subject: [PATCH 1/2] JCL-417: Validate LDP containment data in SolidContainer::getResources method --- .../inrupt/client/solid/SolidContainer.java | 34 ++++++++++++++----- .../inrupt/client/solid/SolidClientTest.java | 18 ++++++++++ .../client/solid/SolidMockHttpService.java | 19 +++++++++++ .../src/test/resources/__files/container.ttl | 25 ++++++++++++++ 4 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 solid/src/test/resources/__files/container.ttl diff --git a/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java b/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java index e5ad2a79654..48ccefb1304 100644 --- a/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java +++ b/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java @@ -77,15 +77,33 @@ public SolidContainer(final URI identifier, final Dataset dataset, final Metadat * @return the contained resources */ public Set getResources() { - final Node node = new Node(rdf.createIRI(getIdentifier().toString()), getGraph()); - try (final Stream stream = node.getResources()) { - return stream.map(child -> { - final Metadata.Builder builder = Metadata.newBuilder(); - getMetadata().getStorage().ifPresent(builder::storage); - child.getTypes().forEach(builder::type); - return new SolidResourceReference(URI.create(child.getIRIString()), builder.build()); - }).collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet)); + final String container = getIdentifier().toString(); + // As defined by the Solid Protocol, containers always end with a slash. + if (container.endsWith("/")) { + final Node node = new Node(rdf.createIRI(getIdentifier().toString()), getGraph()); + try (final Stream stream = node.getResources()) { + return stream.flatMap(child -> { + final URI childLocation = URI.create(child.getIRIString()).normalize(); + // Solid containment is based on URI path hierarchy, + // so all child resources must start with the URL of the parent + if (childLocation.toString().startsWith(container)) { + final String relativePath = childLocation.toString().substring(container.length()); + final String normalizedPath = relativePath.endsWith("/") ? + relativePath.substring(0, relativePath.length() - 1) : relativePath; + // Solid containment occurs via direct decent, + // so any recursively contained resources must not be included + if (!normalizedPath.isEmpty() && !normalizedPath.contains("/")) { + final Metadata.Builder builder = Metadata.newBuilder(); + getMetadata().getStorage().ifPresent(builder::storage); + child.getTypes().forEach(builder::type); + return Stream.of(new SolidResourceReference(childLocation, builder.build())); + } + } + return Stream.empty(); + }).collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet)); + } } + return Collections.emptySet(); } /** diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java b/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java index f2a2b168c13..357ef6b5741 100644 --- a/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java +++ b/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java @@ -28,6 +28,7 @@ import com.inrupt.client.Response; import com.inrupt.client.auth.Session; import com.inrupt.client.spi.RDFFactory; +import com.inrupt.client.util.URIBuilder; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -36,6 +37,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.rdf.api.RDF; @@ -216,6 +218,22 @@ void testGetBinaryCreate() { }).toCompletableFuture().join(); } + @Test + void testSolidContainer() { + final URI uri = URI.create(config.get("solid_resource_uri") + "/container/"); + final Set expected = new HashSet<>(); + expected.add(URIBuilder.newBuilder(uri).path("newContainer/").build()); + expected.add(URIBuilder.newBuilder(uri).path("test.txt").build()); + expected.add(URIBuilder.newBuilder(uri).path("test2.txt").build()); + + client.read(uri, SolidContainer.class).thenAccept(container -> { + try (final SolidContainer c = container) { + assertEquals(expected, + c.getResources().stream().map(SolidResource::getIdentifier).collect(Collectors.toSet())); + } + }).toCompletableFuture().join(); + } + @Test void testBinaryCreate() throws IOException { final URI uri = URI.create(config.get("solid_resource_uri") + "/binary"); diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java b/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java index 67f28f29f83..d0c4becdbc8 100644 --- a/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java +++ b/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java @@ -80,6 +80,25 @@ private void setupMocks() { ) ); + wireMockServer.stubFor(get(urlEqualTo("/container/")) + .withHeader("User-Agent", equalTo(USER_AGENT)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withHeader("Link", Link.of(LDP.BasicContainer, "type").toString()) + .withHeader("Link", Link.of(URI.create("http://acl.example/solid/"), "acl").toString()) + .withHeader("Link", Link.of(URI.create("http://storage.example/"), + PIM.storage).toString()) + .withHeader("Link", Link.of(URI.create("https://history.test/"), "timegate").toString()) + .withHeader("WAC-Allow", "user=\"read write\",public=\"read\"") + .withHeader("Allow", "POST, PUT, PATCH") + .withHeader("Accept-Post", "application/ld+json, text/turtle") + .withHeader("Accept-Put", "application/ld+json, text/turtle") + .withHeader("Accept-Patch", "application/sparql-update, text/n3") + .withBodyFile("container.ttl") + ) + ); + wireMockServer.stubFor(get(urlEqualTo("/recipe")) .withHeader("User-Agent", equalTo(USER_AGENT)) .willReturn(aResponse() diff --git a/solid/src/test/resources/__files/container.ttl b/solid/src/test/resources/__files/container.ttl new file mode 100644 index 00000000000..a6a2175b681 --- /dev/null +++ b/solid/src/test/resources/__files/container.ttl @@ -0,0 +1,25 @@ +@prefix dct: . +@prefix ldp: . +@prefix xsd: . +@prefix pl: . + +<> + a ldp:BasicContainer ; + dct:modified "2022-11-25T10:36:36Z"^^xsd:dateTime; + ldp:contains , , . + + a ldp:BasicContainer ; + dct:modified "2022-11-25T10:36:36Z"^^xsd:dateTime . + + a pl:Resource, ldp:NonRDFSource; + dct:modified "2022-11-25T10:34:14Z"^^xsd:dateTime . + + a pl:Resource, ldp:NonRDFSource; + dct:modified "2022-11-25T10:37:06Z"^^xsd:dateTime . + +# These containment triples should not be included in a getResources response +<> + ldp:contains , , <> , <./> . + + a ldp:BasicContainer ; + ldp:contains . From d3fd371ec4cf43c58fe4166dd0e8c2f129695e36 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Wed, 5 Jul 2023 14:58:07 -0500 Subject: [PATCH 2/2] Use verify mechanism --- .../com/inrupt/client/ValidationResult.java | 12 ++- .../inrupt/client/solid/SolidContainer.java | 96 +++++++++++++++---- .../inrupt/client/solid/SolidClientTest.java | 26 +++-- .../client/solid/SolidMockHttpService.java | 36 ++++++- .../client/solid/SolidSyncClientTest.java | 8 +- 5 files changed, 148 insertions(+), 30 deletions(-) diff --git a/api/src/main/java/com/inrupt/client/ValidationResult.java b/api/src/main/java/com/inrupt/client/ValidationResult.java index 2d6b03ef255..43545c0a7a1 100644 --- a/api/src/main/java/com/inrupt/client/ValidationResult.java +++ b/api/src/main/java/com/inrupt/client/ValidationResult.java @@ -38,8 +38,18 @@ public class ValidationResult { * @param messages the messages from validation method */ public ValidationResult(final boolean valid, final String... messages) { + this(valid, Arrays.asList(messages)); + } + + /** + * Create a ValidationResult object. + * + * @param valid the result from validation + * @param messages the messages from validation method + */ + public ValidationResult(final boolean valid, final List messages) { this.valid = valid; - this.result = Arrays.asList(messages); + this.result = messages; } /** diff --git a/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java b/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java index 48ccefb1304..73a501092a6 100644 --- a/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java +++ b/solid/src/main/java/com/inrupt/client/solid/SolidContainer.java @@ -20,14 +20,18 @@ */ package com.inrupt.client.solid; +import com.inrupt.client.ValidationResult; import com.inrupt.client.vocabulary.LDP; import com.inrupt.client.vocabulary.RDF; import com.inrupt.rdf.wrapping.commons.ValueMappings; import com.inrupt.rdf.wrapping.commons.WrapperIRI; import java.net.URI; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -35,6 +39,7 @@ import org.apache.commons.rdf.api.Graph; import org.apache.commons.rdf.api.IRI; import org.apache.commons.rdf.api.RDFTerm; +import org.apache.commons.rdf.api.Triple; /** * A Solid Container Object. @@ -77,35 +82,45 @@ public SolidContainer(final URI identifier, final Dataset dataset, final Metadat * @return the contained resources */ public Set getResources() { - final String container = getIdentifier().toString(); + final String container = normalize(getIdentifier()); // As defined by the Solid Protocol, containers always end with a slash. if (container.endsWith("/")) { final Node node = new Node(rdf.createIRI(getIdentifier().toString()), getGraph()); try (final Stream stream = node.getResources()) { - return stream.flatMap(child -> { - final URI childLocation = URI.create(child.getIRIString()).normalize(); - // Solid containment is based on URI path hierarchy, - // so all child resources must start with the URL of the parent - if (childLocation.toString().startsWith(container)) { - final String relativePath = childLocation.toString().substring(container.length()); - final String normalizedPath = relativePath.endsWith("/") ? - relativePath.substring(0, relativePath.length() - 1) : relativePath; - // Solid containment occurs via direct decent, - // so any recursively contained resources must not be included - if (!normalizedPath.isEmpty() && !normalizedPath.contains("/")) { - final Metadata.Builder builder = Metadata.newBuilder(); - getMetadata().getStorage().ifPresent(builder::storage); - child.getTypes().forEach(builder::type); - return Stream.of(new SolidResourceReference(childLocation, builder.build())); - } - } - return Stream.empty(); + return stream.filter(child -> verifyContainmentIri(container, child)).map(child -> { + final Metadata.Builder builder = Metadata.newBuilder(); + getMetadata().getStorage().ifPresent(builder::storage); + child.getTypes().forEach(builder::type); + return new SolidResourceReference(URI.create(child.getIRIString()), builder.build()); }).collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet)); } } return Collections.emptySet(); } + @Override + public ValidationResult validate() { + // Get the normalized container URI + final String container = normalize(getIdentifier()); + final List messages = new ArrayList<>(); + // Verify that the container URI path ends with a slash + if (!container.endsWith("/")) { + messages.add("Container URI does not end in a slash"); + } + + // Verify that all ldp:contains triples align with Solid expectations + getGraph().stream(null, rdf.createIRI(LDP.contains.toString()), null) + .collect(Collectors.partitioningBy(verifyContainmentTriple(container))) + .get(false) // we are only concerned with the invalid triples + .forEach(triple -> messages.add("Invalid containment triple: " + triple.getSubject().ntriplesString() + + " ldp:contains " + triple.getObject().ntriplesString() + " .")); + + if (messages.isEmpty()) { + return new ValidationResult(true); + } + return new ValidationResult(false, messages); + } + /** * Retrieve the resources contained in this SolidContainer. * @@ -117,6 +132,49 @@ public Stream getContainedResources() { return getResources().stream(); } + static String normalize(final IRI iri) { + return normalize(URI.create(iri.getIRIString())); + } + + static String normalize(final URI uri) { + return uri.normalize().toString().split("#")[0].split("\\?")[0]; + } + + static Predicate verifyContainmentTriple(final String container) { + final IRI subject = rdf.createIRI(container); + return triple -> { + if (!triple.getSubject().equals(subject)) { + // Out-of-domain containment triple subject + return false; + } + if (triple.getObject() instanceof IRI) { + return verifyContainmentIri(container, (IRI) triple.getObject()); + } + // Non-URI containment triple object + return false; + }; + } + + static boolean verifyContainmentIri(final String container, final IRI object) { + if (!object.getIRIString().startsWith(container)) { + // Out-of-domain containment triple object + return false; + } else { + final String relativePath = object.getIRIString().substring(container.length()); + final String normalizedPath = relativePath.endsWith("/") ? + relativePath.substring(0, relativePath.length() - 1) : relativePath; + if (normalizedPath.isEmpty()) { + // Containment triple subject and object cannot be the same + return false; + } + if (normalizedPath.contains("/")) { + // Containment cannot skip intermediate nodes + return false; + } + } + return true; + } + @SuppressWarnings("java:S2160") // Wrapper equality is correctly delegated to underlying node static final class Node extends WrapperIRI { private final IRI ldpContains = rdf.createIRI(LDP.contains.toString()); diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java b/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java index 357ef6b5741..cef809d5a20 100644 --- a/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java +++ b/solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java @@ -25,6 +25,7 @@ import com.inrupt.client.ClientProvider; import com.inrupt.client.Headers; +import com.inrupt.client.Request; import com.inrupt.client.Response; import com.inrupt.client.auth.Session; import com.inrupt.client.spi.RDFFactory; @@ -163,7 +164,7 @@ void testGetResource() throws IOException, InterruptedException { @Test void testGetContainer() throws IOException, InterruptedException { - final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist"); + final URI uri = URI.create(config.get("solid_resource_uri") + "/playlists/"); client.read(uri, SolidContainer.class).thenAccept(container -> { try (final SolidContainer c = container) { @@ -219,19 +220,28 @@ void testGetBinaryCreate() { } @Test - void testSolidContainer() { + void testSolidContainerWithInvalidData() { + final URI uri = URI.create(config.get("solid_resource_uri") + "/container/"); + final CompletionException err = assertThrows(CompletionException.class, + client.read(uri, SolidContainer.class).toCompletableFuture()::join); + assertInstanceOf(DataMappingException.class, err.getCause()); + } + + @Test + void testLowLevelSolidContainer() { final URI uri = URI.create(config.get("solid_resource_uri") + "/container/"); + final Set expected = new HashSet<>(); expected.add(URIBuilder.newBuilder(uri).path("newContainer/").build()); expected.add(URIBuilder.newBuilder(uri).path("test.txt").build()); expected.add(URIBuilder.newBuilder(uri).path("test2.txt").build()); - client.read(uri, SolidContainer.class).thenAccept(container -> { - try (final SolidContainer c = container) { - assertEquals(expected, - c.getResources().stream().map(SolidResource::getIdentifier).collect(Collectors.toSet())); - } - }).toCompletableFuture().join(); + client.send(Request.newBuilder(uri).build(), SolidResourceHandlers.ofSolidContainer()) + .thenAccept(response -> { + final SolidContainer container = response.body(); + assertEquals(expected, container.getResources().stream() + .map(SolidResource::getIdentifier).collect(Collectors.toSet())); + }).toCompletableFuture().join(); } @Test diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java b/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java index d0c4becdbc8..6871eff5053 100644 --- a/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java +++ b/solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java @@ -149,6 +149,40 @@ private void setupMocks() { .willReturn(aResponse() .withStatus(204))); + wireMockServer.stubFor(get(urlEqualTo("/playlists/")) + .withHeader("User-Agent", equalTo(USER_AGENT)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withHeader("Link", Link.of(LDP.BasicContainer, "type").toString()) + .withHeader("Link", Link.of(URI.create("http://storage.example/"), + PIM.storage).toString()) + .withHeader("Link", Link.of(URI.create("https://history.test/"), "timegate").toString()) + .withHeader("Link", Link.of(URI.create("http://acl.example/playlists"), "acl").toString()) + .withHeader("WAC-Allow", "user=\"read write\",public=\"read\"") + .withHeader("Allow", "POST, PUT, PATCH") + .withHeader("Accept-Post", "application/ld+json, text/turtle") + .withHeader("Accept-Put", "application/ld+json, text/turtle") + .withHeader("Accept-Patch", "application/sparql-update, text/n3") + .withBodyFile("playlist.ttl") + ) + ); + + wireMockServer.stubFor(put(urlEqualTo("/playlists/")) + .withHeader("User-Agent", equalTo(USER_AGENT)) + .withHeader("Content-Type", containing("text/turtle")) + .withRequestBody(containing( + "")) + .willReturn(aResponse() + .withStatus(204))); + + wireMockServer.stubFor(delete(urlEqualTo("/playlists/")) + .withHeader("User-Agent", equalTo(USER_AGENT)) + .willReturn(aResponse() + .withStatus(204))); + + + wireMockServer.stubFor(get(urlEqualTo("/playlist")) .withHeader("User-Agent", equalTo(USER_AGENT)) @@ -159,7 +193,7 @@ private void setupMocks() { .withHeader("Link", Link.of(URI.create("http://storage.example/"), PIM.storage).toString()) .withHeader("Link", Link.of(URI.create("https://history.test/"), "timegate").toString()) - .withHeader("Link", Link.of(URI.create("http://acl.example/playlist"), "acl").toString()) + .withHeader("Link", Link.of(URI.create("http://acl.example/playlists"), "acl").toString()) .withHeader("WAC-Allow", "user=\"read write\",public=\"read\"") .withHeader("Allow", "POST, PUT, PATCH") .withHeader("Accept-Post", "application/ld+json, text/turtle") diff --git a/solid/src/test/java/com/inrupt/client/solid/SolidSyncClientTest.java b/solid/src/test/java/com/inrupt/client/solid/SolidSyncClientTest.java index f50ac859a01..abaf8232c3e 100644 --- a/solid/src/test/java/com/inrupt/client/solid/SolidSyncClientTest.java +++ b/solid/src/test/java/com/inrupt/client/solid/SolidSyncClientTest.java @@ -134,7 +134,7 @@ void testGetResource() { @Test void testGetContainer() { - final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist"); + final URI uri = URI.create(config.get("solid_resource_uri") + "/playlists/"); try (final SolidContainer container = client.read(uri, SolidContainer.class)) { assertEquals(uri, container.getIdentifier()); @@ -156,6 +156,12 @@ void testGetContainer() { } } + @Test + void testGetContainerDataMappingError() { + final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist"); + assertThrows(DataMappingException.class, () -> client.read(uri, SolidContainer.class)); + } + @Test void testGetInvalidType() { final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist");