Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/core/jsonld/jsonld.cc
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ auto run_expansion(ExpansionState &state, ActiveContext &active_context,
}

if (expanded.is_object()) {
if (expanded.empty() || (expanded.object_size() == 1 &&
expanded.defines(KEYWORD_ID, KEYWORD_ID_HASH))) {
return JSON::make_array();
}
// A map that names nothing but which node it is, or nothing at all, is
// already dropped where free-floating values are, and the only member
// that stands in for the whole document carries an array, so whatever
// arrives here as a map has something left to say
assert(!expanded.empty() &&
!(expanded.object_size() == 1 &&
expanded.defines(KEYWORD_ID, KEYWORD_ID_HASH)));
auto result{JSON::make_array()};
result.push_back(std::move(expanded));
return result;
Expand Down
37 changes: 10 additions & 27 deletions src/core/jsonld/jsonld_compaction.cc
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,6 @@ auto add_value(JSON &result, const JSON::String &key, JSON &&value,
existing.push_back(std::move(value));
}

// A value object carrying a JSON literal whose value is null.
auto is_json_literal_null(const JSON &element) -> bool {
// Whatever stands as the value of a property in an expanded document is an
// object, which is what the one place this is asked from hands over
assert(element.is_object());
const auto *const contents{element.try_at(KEYWORD_VALUE, KEYWORD_VALUE_HASH)};
if (contents == nullptr || !contents->is_null()) {
return false;
}
const auto *const type{element.try_at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)};
return type != nullptr && type->is_string() &&
type->to_string() == KEYWORD_JSON;
}

// The object a property is written into: the result itself, or a nesting
// container when the term carries an @nest mapping.
auto nest_target(JSON &result, const TermDefinition *const definition,
Expand Down Expand Up @@ -109,25 +95,22 @@ auto compact(ExpansionState &state, const ActiveContext &active_context,
throw JSONLDError("Maximum nesting depth exceeded", EMPTY_WEAK_POINTER);
}

if (!element.is_object() && !element.is_array()) {
return element;
}
// Compaction walks an expanded document, where a scalar only ever stands as
// what a value object holds, which never reaches here on its own
assert(element.is_object() || element.is_array());

const auto *definition{definition_for(active_context, active_property)};

if (element.is_array()) {
auto result{JSON::make_array()};
for (const auto &item : element.as_array()) {
auto compacted{compact(state, active_context, inverse_context,
active_property, item, compact_arrays)};
// A JSON literal holding null compacts to a bare null under a term
// coerced to @json, and that null is data rather than absence, so it
// survives even though the algorithm otherwise drops null items
// (JSON-LD 1.1 API Section 6.1.2: "If compacted item is not null, then
// append it to result"). Dropping it would silently lose the literal.
if (!compacted.is_null() || is_json_literal_null(item)) {
result.push_back(std::move(compacted));
}
// The step that drops a null item (JSON-LD 1.1 API Section 6.1.2: "If
// compacted item is not null, then append it to result") has nothing to
// act on here. The only null an expanded item compacts to is the one a
// JSON literal holds, which is data rather than absence, and dropping it
// would silently lose the literal
result.push_back(compact(state, active_context, inverse_context,
active_property, item, compact_arrays));
}
const bool force_array{!compact_arrays ||
(active_property.has_value() &&
Expand Down
7 changes: 4 additions & 3 deletions src/core/jsonld/jsonld_context_processing.cc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <sourcemeta/core/numeric.h>
#include <sourcemeta/core/uri.h>

#include <cassert> // assert
#include <memory> // std::make_shared
#include <optional> // std::optional
#include <utility> // std::move, std::pair
Expand Down Expand Up @@ -100,9 +101,9 @@ auto is_version_1_1(const JSON &version) -> bool {
auto is_input_authored(const JSONLDError &error, const JSON &context,
const WeakPointer &location) -> bool {
const auto &pointer{error.pointer()};
if (pointer.size() <= location.size()) {
return true;
}
// Everything the merged context is held to account for is one of its
// entries, so what reports against it reaches past the context itself
assert(pointer.size() > location.size());
const auto &token{pointer.at(location.size())};
return token.is_property() && context.defines(token.to_property());
}
Expand Down
41 changes: 26 additions & 15 deletions src/core/jsonld/jsonld_expansion.cc
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,23 @@ auto container_includes(const TermDefinition *const definition,
return false;
}

// Whether the items being expanded become the items of a list, which is
// either the list they are written in or the one the property they stand
// under collects them into
auto collects_into_a_list(const ActiveContext &active_context,
const std::optional<JSON::String> &active_property,
const JSON::StringView name) -> bool {
if (name == KEYWORD_LIST) {
return true;
}

const auto term{active_property.has_value()
? active_context.terms.find(active_property.value())
: active_context.terms.cend()};
return term != active_context.terms.cend() &&
container_includes(&term->second, KEYWORD_LIST);
}

// Expand a single @type value against the context that preceded type-scoped
// processing.
auto expand_type(ExpansionState &state, const ActiveContext &type_context,
Expand Down Expand Up @@ -693,7 +710,11 @@ auto expand_entries(ExpansionState &state, ActiveContext &active_context,
}
value_index += 1;
}
if (name == KEYWORD_LIST && state.processing_1_0) {
// JSON-LD 1.0 refuses a list among the items of a list, whichever
// keyword hands those items over (JSON-LD 1.0 API Expansion Algorithm
// step 3.2)
if (state.processing_1_0 &&
collects_into_a_list(active_context, active_property, name)) {
for (const auto &item : elements.as_array()) {
if (is_list_object(item)) {
throw JSONLDError("List of lists",
Expand Down Expand Up @@ -1200,22 +1221,12 @@ auto expand_entries(ExpansionState &state, ActiveContext &active_context,
}

// A @list container wraps the expanded value, including a @json-coerced
// one.
// one. A list among the items of the list this makes is refused by
// JSON-LD 1.0 where the array form and the keyword form gather those
// items, and welcomed by 1.1, so neither leaves anything to settle here
if (container_includes(definition, KEYWORD_LIST) &&
!expanded_value.is_null() &&
!(expanded_value.is_object() &&
expanded_value.defines(KEYWORD_LIST, KEYWORD_LIST_HASH))) {
!expanded_value.is_null() && !is_list_object(expanded_value)) {

@augmentcode augmentcode Bot Oct 14, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you check the @set path at src/core/jsonld/jsonld_expansion.cc:1207: with JSONLDVersion::V1_0, {"@context":{"p":{"@id":"urn:p","@container":"@list"}},"p":{"@set":{"@list":["x"]}}} now succeeds instead of throwing List of lists? @set collapses to an array containing a list object without entering either remaining nesting check, so this wrapper creates a forbidden list of lists that the removed loop previously rejected.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

expanded_value = into_array(std::move(expanded_value));
if (state.processing_1_0) {
for (const auto &item : expanded_value.as_array()) {
if (is_list_object(item)) {
throw JSONLDError("List of lists",
forbidden_member_pointer(
state, active_context, property, entry.second,
entry_pointer, is_list_object));
}
}
}
auto wrapper{JSON::make_object()};
wrapper.assign_assume_new(JSON::String{KEYWORD_LIST},
std::move(expanded_value), KEYWORD_LIST_HASH);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Expand Down
7 changes: 4 additions & 3 deletions src/core/jsonld/jsonld_iri_compaction.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "jsonld_algorithms.h"
#include "jsonld_keywords.h"

#include <cassert> // assert
#include <optional> // std::optional, std::nullopt
#include <vector> // std::vector

Expand Down Expand Up @@ -210,9 +211,9 @@ auto compact_iri(const ActiveContext &active_context,
common_type = JSON::String{KEYWORD_NONE};
}
}
if (!common_language.has_value()) {
common_language = JSON::String{KEYWORD_NONE};
}
// An empty list takes the default language, and any other settles one
// from its first item, so the language is spoken for either way
assert(common_language.has_value());
if (!common_type.has_value()) {
common_type = JSON::String{KEYWORD_NONE};
}
Expand Down
16 changes: 7 additions & 9 deletions src/core/jsonld/jsonld_node_map.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "jsonld_keywords.h"

#include <algorithm> // std::ranges::sort
#include <cassert> // assert
#include <optional> // std::optional, std::nullopt
#include <string> // std::to_string
#include <utility> // std::move
Expand All @@ -14,12 +15,11 @@ namespace sourcemeta::core {

namespace {

// Append a value to the property array of node, creating the array as needed.
// Append a value to the property array of node, which the caller makes before
// it descends into the values that land there
auto append_value(JSON &node, const JSON::StringView property, JSON value)
-> void {
if (!node.defines(property)) {
node.assign_assume_new(JSON::String{property}, JSON::make_array());
}
assert(node.defines(property));
node.at(property).push_back(std::move(value));
}

Expand Down Expand Up @@ -80,7 +80,9 @@ auto generate_node_map(BlankNodeState &state, JSON &node_map,
// The algorithm consumes entries of element, so work on a mutable copy.
JSON working{element};

// Relabel blank node identifiers used as @type values.
// Relabel blank node identifiers used as @type values. A node object states
// its types as an array, and the datatype a value object states is an IRI,
// so a name to relabel only ever stands among the items of an array
if (working.defines(KEYWORD_TYPE, KEYWORD_TYPE_HASH)) {
auto &type_value{working.at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)};
if (type_value.is_array()) {
Expand All @@ -89,10 +91,6 @@ auto generate_node_map(BlankNodeState &state, JSON &node_map,
item = JSON{generate_blank_node_identifier(state, item.to_string())};
}
}
} else if (type_value.is_string() &&
is_blank_node(type_value.to_string())) {
type_value =
JSON{generate_blank_node_identifier(state, type_value.to_string())};
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/jsonld/jsonld_serialise.h
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,10 @@ inline auto typed_literal_lexical_form(const JSON &value,
}
return fixed_lexical_form(decimal);
}
case JSON::Type::Null:
case JSON::Type::String:
case JSON::Type::Array:
case JSON::Type::Object:
case JSON::Type::Null:
return std::nullopt;
}

Expand Down
24 changes: 24 additions & 0 deletions test/jsonld/jsonld_compact_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@
#include <string> // std::string
#include <utility> // std::move

// A document-relative identifier that names nothing beyond the document
// stays as it is when no base settles what it points at, and it compacts to
// itself rather than through the context
TEST(empty_identifier_without_a_base) {
const auto input = sourcemeta::core::parse_json(R"([
{
"@id": "",
"http://example.com/b": [ { "@value": "v" } ]
}
])");

const auto context = sourcemeta::core::parse_json(R"({
"b": "http://example.com/b"
})");

const auto expected = sourcemeta::core::parse_json(R"({
"@id": "",
"b": "v",
"@context": { "b": "http://example.com/b" }
})");

EXPECT_EQ(sourcemeta::core::jsonld_compact(input, context), expected);
}

TEST(compact_to_relative_true_relativises_against_base) {
const auto input = sourcemeta::core::parse_json(R"([
{
Expand Down
90 changes: 90 additions & 0 deletions test/jsonld/jsonld_expand_error_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,55 @@ TEST(reverse_map_with_ambiguous_aliases_names_the_offender) {
"Invalid reverse property value", "/@reverse/b");
}

// Several keys of a reverse map may reach the same property, so each is
// expanded again to find the one at fault, and a key carrying nothing but node
// objects is passed over however many of them it holds
TEST(reverse_map_with_ambiguous_aliases_over_an_array) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": { "a": "http://e/p", "b": "http://e/p" },
"@reverse": {
"a": [ { "@id": "http://e/s" }, { "@id": "http://e/t" } ],
"b": { "@value": "v" }
}
})");

EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input),
"Invalid reverse property value", "/@reverse/b");
}

// An index map turns its keys into values of the property the map stands
// under, which the search for the offending key cannot reproduce from the key
// alone, so the map as a whole answers for it
TEST(reverse_map_over_an_index_container_names_the_map) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": {
"@version": 1.1,
"p": { "@id": "http://e/p", "@container": "@index" }
},
"@reverse": { "p": { "k": "v" } }
})");

EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input),
"Invalid reverse property value", "/@reverse");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This assertion expects the wrong pointer: the matched offending input is /@reverse/p, and forbidden_member_pointer falls back to that key's pointer. Expect /@reverse/p so this test passes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At test/jsonld/jsonld_expand_error_test.cc, line 1418:

<comment>This assertion expects the wrong pointer: the matched offending input is `/@reverse/p`, and `forbidden_member_pointer` falls back to that key's pointer. Expect `/@reverse/p` so this test passes.</comment>

<file context>
@@ -1386,6 +1386,55 @@ TEST(reverse_map_with_ambiguous_aliases_names_the_offender) {
+  })");
+
+  EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input),
+                             "Invalid reverse property value", "/@reverse");
+}
+
</file context>
Suggested change
"Invalid reverse property value", "/@reverse");
"Invalid reverse property value", "/@reverse/p");

}

// An index map is read key by key, so a context entry among its keys is an
// index like any other. Tracing the offending member back reads the map as the
// object it looks like, where that entry is a context that does not process,
// and the whole map answers for the member
TEST(reverse_term_over_an_index_map_holding_a_context_entry) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": {
"@version": 1.1,
"r": { "@reverse": "http://e/r", "@container": "@index" }
},
"r": { "@context": 42, "k": "v" }
})");

EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input),
"Invalid reverse property value", "/r");
}

TEST(reverse_term_with_an_array_container) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": {
Expand Down Expand Up @@ -2659,6 +2708,21 @@ TEST(legacy_list_container_member_keeps_index) {
"List of lists", "/p/1");
}

// A set hands its members to the property that stands over it, so a list
// among them becomes a member of the list that property collects, which
// JSON-LD 1.0 refuses just as it refuses one written in an array
TEST(legacy_list_container_set_member_keeps_index) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": { "p": { "@id": "urn:p", "@container": "@list" } },
"p": { "@set": [ null, { "@list": [ "x" ] } ] }
})");

EXPECT_JSONLD_EXPAND_ERROR(
sourcemeta::core::jsonld_expand(input, "", {},
sourcemeta::core::JSONLDVersion::V1_0),
"List of lists", "/p/@set/1");
}

TEST(protected_type_empty_redefinition) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": [ { "@type": { "@protected": true } }, { "@type": {} } ],
Expand Down Expand Up @@ -3295,3 +3359,29 @@ TEST(list_of_lists_under_a_list_container_in_1_0) {
EXPECT_STREQ(error.what(), "List of lists");
}
}

// Several terms may name the same reverse property, and what they carry is
// merged before it is inspected, so each is read again to find the one that
// holds the offending member
TEST(reverse_map_value_object_under_two_terms) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": { "p": "http://e/p", "q": "http://e/p" },
"@reverse": { "p": { "@id": "http://e/o" }, "q": { "@value": "v" } }
})");

EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input),
"Invalid reverse property value", "/@reverse/q");
}

// Several terms may name the same property, and what they carry is merged
// before the value object is inspected, so none of them can be held to
// account for it and the object itself is named instead
TEST(value_object_with_a_property_under_two_terms) {
const auto input = sourcemeta::core::parse_json(R"({
"@context": { "a": "http://e/p", "b": "http://e/p" },
"http://e/q": { "@value": "x", "a": "1", "b": "2" }
})");

EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input),
"Invalid value object", "/http:~1~1e~1q");
}
Loading
Loading