Skip to content

Commit ad1bac6

Browse files
authored
JCL-417: Validate LDP containment data in SolidContainer::getResources (#571)
* JCL-417: Validate LDP containment data in SolidContainer::getResources method * Use verify mechanism
1 parent 894e8ce commit ad1bac6

File tree

6 files changed

+210
-12
lines changed

6 files changed

+210
-12
lines changed

api/src/main/java/com/inrupt/client/ValidationResult.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,18 @@ public class ValidationResult {
3838
* @param messages the messages from validation method
3939
*/
4040
public ValidationResult(final boolean valid, final String... messages) {
41+
this(valid, Arrays.asList(messages));
42+
}
43+
44+
/**
45+
* Create a ValidationResult object.
46+
*
47+
* @param valid the result from validation
48+
* @param messages the messages from validation method
49+
*/
50+
public ValidationResult(final boolean valid, final List<String> messages) {
4151
this.valid = valid;
42-
this.result = Arrays.asList(messages);
52+
this.result = messages;
4353
}
4454

4555
/**

solid/src/main/java/com/inrupt/client/solid/SolidContainer.java

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,26 @@
2020
*/
2121
package com.inrupt.client.solid;
2222

23+
import com.inrupt.client.ValidationResult;
2324
import com.inrupt.client.vocabulary.LDP;
2425
import com.inrupt.client.vocabulary.RDF;
2526
import com.inrupt.rdf.wrapping.commons.ValueMappings;
2627
import com.inrupt.rdf.wrapping.commons.WrapperIRI;
2728

2829
import java.net.URI;
30+
import java.util.ArrayList;
2931
import java.util.Collections;
32+
import java.util.List;
3033
import java.util.Set;
34+
import java.util.function.Predicate;
3135
import java.util.stream.Collectors;
3236
import java.util.stream.Stream;
3337

3438
import org.apache.commons.rdf.api.Dataset;
3539
import org.apache.commons.rdf.api.Graph;
3640
import org.apache.commons.rdf.api.IRI;
3741
import org.apache.commons.rdf.api.RDFTerm;
42+
import org.apache.commons.rdf.api.Triple;
3843

3944
/**
4045
* A Solid Container Object.
@@ -77,15 +82,43 @@ public SolidContainer(final URI identifier, final Dataset dataset, final Metadat
7782
* @return the contained resources
7883
*/
7984
public Set<SolidResource> getResources() {
80-
final Node node = new Node(rdf.createIRI(getIdentifier().toString()), getGraph());
81-
try (final Stream<Node.TypedNode> stream = node.getResources()) {
82-
return stream.map(child -> {
83-
final Metadata.Builder builder = Metadata.newBuilder();
84-
getMetadata().getStorage().ifPresent(builder::storage);
85-
child.getTypes().forEach(builder::type);
86-
return new SolidResourceReference(URI.create(child.getIRIString()), builder.build());
87-
}).collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));
85+
final String container = normalize(getIdentifier());
86+
// As defined by the Solid Protocol, containers always end with a slash.
87+
if (container.endsWith("/")) {
88+
final Node node = new Node(rdf.createIRI(getIdentifier().toString()), getGraph());
89+
try (final Stream<Node.TypedNode> stream = node.getResources()) {
90+
return stream.filter(child -> verifyContainmentIri(container, child)).map(child -> {
91+
final Metadata.Builder builder = Metadata.newBuilder();
92+
getMetadata().getStorage().ifPresent(builder::storage);
93+
child.getTypes().forEach(builder::type);
94+
return new SolidResourceReference(URI.create(child.getIRIString()), builder.build());
95+
}).collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));
96+
}
97+
}
98+
return Collections.emptySet();
99+
}
100+
101+
@Override
102+
public ValidationResult validate() {
103+
// Get the normalized container URI
104+
final String container = normalize(getIdentifier());
105+
final List<String> messages = new ArrayList<>();
106+
// Verify that the container URI path ends with a slash
107+
if (!container.endsWith("/")) {
108+
messages.add("Container URI does not end in a slash");
109+
}
110+
111+
// Verify that all ldp:contains triples align with Solid expectations
112+
getGraph().stream(null, rdf.createIRI(LDP.contains.toString()), null)
113+
.collect(Collectors.partitioningBy(verifyContainmentTriple(container)))
114+
.get(false) // we are only concerned with the invalid triples
115+
.forEach(triple -> messages.add("Invalid containment triple: " + triple.getSubject().ntriplesString() +
116+
" ldp:contains " + triple.getObject().ntriplesString() + " ."));
117+
118+
if (messages.isEmpty()) {
119+
return new ValidationResult(true);
88120
}
121+
return new ValidationResult(false, messages);
89122
}
90123

91124
/**
@@ -99,6 +132,49 @@ public Stream<SolidResource> getContainedResources() {
99132
return getResources().stream();
100133
}
101134

135+
static String normalize(final IRI iri) {
136+
return normalize(URI.create(iri.getIRIString()));
137+
}
138+
139+
static String normalize(final URI uri) {
140+
return uri.normalize().toString().split("#")[0].split("\\?")[0];
141+
}
142+
143+
static Predicate<Triple> verifyContainmentTriple(final String container) {
144+
final IRI subject = rdf.createIRI(container);
145+
return triple -> {
146+
if (!triple.getSubject().equals(subject)) {
147+
// Out-of-domain containment triple subject
148+
return false;
149+
}
150+
if (triple.getObject() instanceof IRI) {
151+
return verifyContainmentIri(container, (IRI) triple.getObject());
152+
}
153+
// Non-URI containment triple object
154+
return false;
155+
};
156+
}
157+
158+
static boolean verifyContainmentIri(final String container, final IRI object) {
159+
if (!object.getIRIString().startsWith(container)) {
160+
// Out-of-domain containment triple object
161+
return false;
162+
} else {
163+
final String relativePath = object.getIRIString().substring(container.length());
164+
final String normalizedPath = relativePath.endsWith("/") ?
165+
relativePath.substring(0, relativePath.length() - 1) : relativePath;
166+
if (normalizedPath.isEmpty()) {
167+
// Containment triple subject and object cannot be the same
168+
return false;
169+
}
170+
if (normalizedPath.contains("/")) {
171+
// Containment cannot skip intermediate nodes
172+
return false;
173+
}
174+
}
175+
return true;
176+
}
177+
102178
@SuppressWarnings("java:S2160") // Wrapper equality is correctly delegated to underlying node
103179
static final class Node extends WrapperIRI {
104180
private final IRI ldpContains = rdf.createIRI(LDP.contains.toString());

solid/src/test/java/com/inrupt/client/solid/SolidClientTest.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525

2626
import com.inrupt.client.ClientProvider;
2727
import com.inrupt.client.Headers;
28+
import com.inrupt.client.Request;
2829
import com.inrupt.client.Response;
2930
import com.inrupt.client.auth.Session;
3031
import com.inrupt.client.spi.RDFFactory;
32+
import com.inrupt.client.util.URIBuilder;
3133

3234
import java.io.ByteArrayInputStream;
3335
import java.io.IOException;
@@ -36,6 +38,7 @@
3638
import java.util.*;
3739
import java.util.concurrent.CompletableFuture;
3840
import java.util.concurrent.CompletionException;
41+
import java.util.stream.Collectors;
3942
import java.util.stream.Stream;
4043

4144
import org.apache.commons.rdf.api.RDF;
@@ -161,7 +164,7 @@ void testGetResource() throws IOException, InterruptedException {
161164

162165
@Test
163166
void testGetContainer() throws IOException, InterruptedException {
164-
final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist");
167+
final URI uri = URI.create(config.get("solid_resource_uri") + "/playlists/");
165168

166169
client.read(uri, SolidContainer.class).thenAccept(container -> {
167170
try (final SolidContainer c = container) {
@@ -216,6 +219,31 @@ void testGetBinaryCreate() {
216219
}).toCompletableFuture().join();
217220
}
218221

222+
@Test
223+
void testSolidContainerWithInvalidData() {
224+
final URI uri = URI.create(config.get("solid_resource_uri") + "/container/");
225+
final CompletionException err = assertThrows(CompletionException.class,
226+
client.read(uri, SolidContainer.class).toCompletableFuture()::join);
227+
assertInstanceOf(DataMappingException.class, err.getCause());
228+
}
229+
230+
@Test
231+
void testLowLevelSolidContainer() {
232+
final URI uri = URI.create(config.get("solid_resource_uri") + "/container/");
233+
234+
final Set<URI> expected = new HashSet<>();
235+
expected.add(URIBuilder.newBuilder(uri).path("newContainer/").build());
236+
expected.add(URIBuilder.newBuilder(uri).path("test.txt").build());
237+
expected.add(URIBuilder.newBuilder(uri).path("test2.txt").build());
238+
239+
client.send(Request.newBuilder(uri).build(), SolidResourceHandlers.ofSolidContainer())
240+
.thenAccept(response -> {
241+
final SolidContainer container = response.body();
242+
assertEquals(expected, container.getResources().stream()
243+
.map(SolidResource::getIdentifier).collect(Collectors.toSet()));
244+
}).toCompletableFuture().join();
245+
}
246+
219247
@Test
220248
void testBinaryCreate() throws IOException {
221249
final URI uri = URI.create(config.get("solid_resource_uri") + "/binary");

solid/src/test/java/com/inrupt/client/solid/SolidMockHttpService.java

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,25 @@ private void setupMocks() {
8080
)
8181
);
8282

83+
wireMockServer.stubFor(get(urlEqualTo("/container/"))
84+
.withHeader("User-Agent", equalTo(USER_AGENT))
85+
.willReturn(aResponse()
86+
.withStatus(200)
87+
.withHeader("Content-Type", "text/turtle")
88+
.withHeader("Link", Link.of(LDP.BasicContainer, "type").toString())
89+
.withHeader("Link", Link.of(URI.create("http://acl.example/solid/"), "acl").toString())
90+
.withHeader("Link", Link.of(URI.create("http://storage.example/"),
91+
PIM.storage).toString())
92+
.withHeader("Link", Link.of(URI.create("https://history.test/"), "timegate").toString())
93+
.withHeader("WAC-Allow", "user=\"read write\",public=\"read\"")
94+
.withHeader("Allow", "POST, PUT, PATCH")
95+
.withHeader("Accept-Post", "application/ld+json, text/turtle")
96+
.withHeader("Accept-Put", "application/ld+json, text/turtle")
97+
.withHeader("Accept-Patch", "application/sparql-update, text/n3")
98+
.withBodyFile("container.ttl")
99+
)
100+
);
101+
83102
wireMockServer.stubFor(get(urlEqualTo("/recipe"))
84103
.withHeader("User-Agent", equalTo(USER_AGENT))
85104
.willReturn(aResponse()
@@ -130,6 +149,40 @@ private void setupMocks() {
130149
.willReturn(aResponse()
131150
.withStatus(204)));
132151

152+
wireMockServer.stubFor(get(urlEqualTo("/playlists/"))
153+
.withHeader("User-Agent", equalTo(USER_AGENT))
154+
.willReturn(aResponse()
155+
.withStatus(200)
156+
.withHeader("Content-Type", "text/turtle")
157+
.withHeader("Link", Link.of(LDP.BasicContainer, "type").toString())
158+
.withHeader("Link", Link.of(URI.create("http://storage.example/"),
159+
PIM.storage).toString())
160+
.withHeader("Link", Link.of(URI.create("https://history.test/"), "timegate").toString())
161+
.withHeader("Link", Link.of(URI.create("http://acl.example/playlists"), "acl").toString())
162+
.withHeader("WAC-Allow", "user=\"read write\",public=\"read\"")
163+
.withHeader("Allow", "POST, PUT, PATCH")
164+
.withHeader("Accept-Post", "application/ld+json, text/turtle")
165+
.withHeader("Accept-Put", "application/ld+json, text/turtle")
166+
.withHeader("Accept-Patch", "application/sparql-update, text/n3")
167+
.withBodyFile("playlist.ttl")
168+
)
169+
);
170+
171+
wireMockServer.stubFor(put(urlEqualTo("/playlists/"))
172+
.withHeader("User-Agent", equalTo(USER_AGENT))
173+
.withHeader("Content-Type", containing("text/turtle"))
174+
.withRequestBody(containing(
175+
"<https://library.test/12345/song1.mp3>"))
176+
.willReturn(aResponse()
177+
.withStatus(204)));
178+
179+
wireMockServer.stubFor(delete(urlEqualTo("/playlists/"))
180+
.withHeader("User-Agent", equalTo(USER_AGENT))
181+
.willReturn(aResponse()
182+
.withStatus(204)));
183+
184+
185+
133186

134187
wireMockServer.stubFor(get(urlEqualTo("/playlist"))
135188
.withHeader("User-Agent", equalTo(USER_AGENT))
@@ -140,7 +193,7 @@ private void setupMocks() {
140193
.withHeader("Link", Link.of(URI.create("http://storage.example/"),
141194
PIM.storage).toString())
142195
.withHeader("Link", Link.of(URI.create("https://history.test/"), "timegate").toString())
143-
.withHeader("Link", Link.of(URI.create("http://acl.example/playlist"), "acl").toString())
196+
.withHeader("Link", Link.of(URI.create("http://acl.example/playlists"), "acl").toString())
144197
.withHeader("WAC-Allow", "user=\"read write\",public=\"read\"")
145198
.withHeader("Allow", "POST, PUT, PATCH")
146199
.withHeader("Accept-Post", "application/ld+json, text/turtle")

solid/src/test/java/com/inrupt/client/solid/SolidSyncClientTest.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ void testGetResource() {
134134

135135
@Test
136136
void testGetContainer() {
137-
final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist");
137+
final URI uri = URI.create(config.get("solid_resource_uri") + "/playlists/");
138138

139139
try (final SolidContainer container = client.read(uri, SolidContainer.class)) {
140140
assertEquals(uri, container.getIdentifier());
@@ -156,6 +156,12 @@ void testGetContainer() {
156156
}
157157
}
158158

159+
@Test
160+
void testGetContainerDataMappingError() {
161+
final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist");
162+
assertThrows(DataMappingException.class, () -> client.read(uri, SolidContainer.class));
163+
}
164+
159165
@Test
160166
void testGetInvalidType() {
161167
final URI uri = URI.create(config.get("solid_resource_uri") + "/playlist");
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
@prefix dct: <http://purl.org/dc/terms/>.
2+
@prefix ldp: <http://www.w3.org/ns/ldp#>.
3+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
4+
@prefix pl: <http://www.w3.org/ns/iana/media-types/text/plain#>.
5+
6+
<>
7+
a ldp:BasicContainer ;
8+
dct:modified "2022-11-25T10:36:36Z"^^xsd:dateTime;
9+
ldp:contains <newContainer/>, <test.txt>, <test2.txt> .
10+
<newContainer/>
11+
a ldp:BasicContainer ;
12+
dct:modified "2022-11-25T10:36:36Z"^^xsd:dateTime .
13+
<test.txt>
14+
a pl:Resource, ldp:NonRDFSource;
15+
dct:modified "2022-11-25T10:34:14Z"^^xsd:dateTime .
16+
<test2.txt>
17+
a pl:Resource, ldp:NonRDFSource;
18+
dct:modified "2022-11-25T10:37:06Z"^^xsd:dateTime .
19+
20+
# These containment triples should not be included in a getResources response
21+
<>
22+
ldp:contains <https://example.com/other> , <newContainer/child> , <> , <./> .
23+
<https://example.test/container/>
24+
a ldp:BasicContainer ;
25+
ldp:contains <https://example.test/container/external> .

0 commit comments

Comments
 (0)