Conversation
- SecuredStatementIterator.remove()/removeNext() Read→Delete escalation. remove() now enforces graph Update + triple Delete on the statement captured from next() (mirroring SecuredStatementImpl.remove()), and removeNext() routes through it. The finding understated the surface: filterKeep/filterDrop/mapWith/andThen handed back the inner unchecked chain, so listStatements().filterKeep(s -> true).removeNext() would have sidestepped a remove-only fix — derived iterators now wrap this so removal stays guarded. The escalation was real: the permitted-path test proves a Jena 6.1.0 mem-model iterator remove really deletes from the graph. - rewriter passed OpPropFunc/OpProcedure through. Both now go through passThroughOrDeny, failing closed like OpPath/OpQuad*. Recorded in the note: on the live engine path this is defense-in-depth (the optimizer manufactures OpPropFunc after the rewriter runs, and those execute against the secured graph proxy's filtered find()); the residual is external-index property functions like jena-text, none of which Halcyon ships. - SecuredNodeIterator/SecuredResIterator unguarded. A node can't be mapped back to the triple(s) a removal mutates, so both iterators now require Update + Delete-over-Triple.ANY, failing closed for per-triple principals.
The listing is now a SelectDataProvider + AjaxFallbackDefaultDataTable: the H6 admin/creator/wac:Read rule rides in as the provider's row filter (via a new principal-free StackStore.canReadStack overload, same rule, single authority), so the count and paging windows only ever reflect rows the user may see. Row selection is node-keyed (SelectionColumn + NodeSelection keyed by the stack graph) with writability as the eligibility gate and a select-all header; delete re-derives each stack's root subject in-txn for the creator check and ACL cleanup. Replaces the hand-rolled ListView/CheckGroup/loadStacks machinery; adds paging, header sorting, and per-render freshness.
A new /lwscontainers screen (AUTHENTICATED, in the menu) that browses the W3C Linked Web Storage hierarchy strictly through the LWS APIs — every byte arrives over HTTP via LwsClient with the signed-in user's own bearer token, so what the page shows is exactly what ACP grants that user. - Tree over the selected storage's root container (any configured storage selectable): expanding a container lists its sub-containers and resources with the listing's lws:mediaType/size metadata. Pagination follows the protocol's opaque Link-header cursors (never fabricates a page URI) with a per-container window at a chosen page size; the media-type filter narrows resources, never navigation. - Right-click Properties on any item: admins edit WHO HAS ACCESS by rewriting the item's own ACP access control resource (rel=acl -> GET .acr as Turtle -> conditional PUT; the storage still demands acl:Control). The AcrDoc translator only offers the structured editor when it can rebuild the document faithfully — deny/noneOf/client matchers/validity windows drop to raw Turtle rather than silently rewriting rules; structural nodes are blank on purpose. Non-admins file an ODRL AccessRequest with the Data Sharing Service instead. - A right-hand preview panel renders the selection with a default viewer by media type: img/video/audio/PDF stream through a page-scoped relay that carries the session token (no open proxy — configured storages only, passive media only, nosniff); text-like types are a bounded (256 kB) escaped server-side fetch. HTML and SVG get source view, never same-origin rendering — relaying them would be stored XSS. LwsClient grows Link-header parsing, HEAD, raw-Turtle GET with ETag, conditional PUT, and streaming/bounded-preview reads. Mount + access are registered in PageAccess (test-enforced); AcrDoc, PreviewKind and the link parsing are unit-tested.
The preview panel now asks the vg:MediaBinding shapes (vandegraph 0.2.0-SNAPSHOT) which viewer a media type gets: the resolved default renders, and when the bindings list alternates an "open with" picker offers them, filtered to viewers actually registered in MediaRegistry. Reader-discovered RDF types now survive listing parse into Entry so vg:rdfType selectors (e.g. zeph:Stack) can match. Host security stays code-enforced regardless of bindings data: the token relay's src is handed only to PreviewKind-relayable passive media, and text content is fetched bounded server-side. PreviewKind's javadoc now states its narrowed job — the relay whitelist and the text heuristic; viewer CHOICE lives in the shapes.
Zephyr joins the vandegraph media layer: hal:ZephyrViewer and hal:ZephyrEditor are registered in the MediaRegistry (code) and selected by Halcyon's vg:MediaBinding overlay (data, halcyon/media-bindings.ttl) — whole-slide TIFFs open in Zephyr by exact-type override of the defaults' image/* pattern (the plain image viewer stays listed as an alternate), and resources the LWS readers typed zeph:Stack open as that stack. This file is where default viewers/editors per media type are specified and alternates listed. The v1 wrapper embeds Zephyr3 in a same-origin iframe via a new bookmarkable entry (?stack= / ?image=). No new authority: access is enforced on the page class (PageAccess AUTHENTICATED) however it is reached, OPEN_STACK still runs the H6 read check in loadGraph, and Save still authorizes through StackStore. Honest limit: Zephyr loads imagery through /iiif/, so LWS-only slides need that pipeline taught to read from the storage before this binding delivers on them. Also fixes the classpath landmine this surfaced: BeakGraph's jar bundles stale com.ebremer.ns copies (HAL, LWS, EXIF, GEO, GEOF, PYR) and preceded halcyon-core, so NEW vocabulary constants failed to resolve at compile time. halcyon-core now comes first, with a pom comment saying why it must.
New ui.md covers the /storage browser and the /lwscontainers container tree: the everything-over-LwsClient rule, protocol-cursor pagination that never fabricates page URIs, the media-type filter, the right-click access properties (admin ACP editor with the AcrDoc faithfulness guard and conditional PUT; ODRL access requests for everyone else), and the media-binding-driven preview pane — Halcyon's overlay binding Zephyr to whole-slide TIFFs and zeph:Stack resources, the token relay's passive-media whitelist, bounded text previews, and the HTML/SVG-as-source-view stance. Indexed from docs/lws/README.md (whose reactor note now names both UIs) and the root README.
Each storage gains a reserved .iiif endpoint (unmintable by clients, like .description) and advertises it in the storage description — a capability entry typed by the IIIF protocol IRI (http://iiif.io/api/image) plus an ImageService entry in the service list — ONLY when an imaging implementation is actually installed: a capability entry is a contract, not decoration. The servlet owns everything protocol- and policy-shaped: the query dialect (?iiif={imageUri}/{region}/{size}/{rotation}/{quality}.{format} or {imageUri}/info.json), confinement of the image identity to a data resource of THIS storage (an image service, not an open proxy), the ACP acl:Read demand on that resource, and resolution of its bytes to the content-store path. The imaging itself — tile engine, format readers — lives above this module, so it arrives as a pluggable IiifService handed to the servlet at construction; absent one, the endpoint 404s and nothing is advertised.
LwsIiifBridge implements the storages' IiifService with the existing engine — tile cache, reader pool, output budgets — pointed at the LWS resource's blob. The wiring is careful about H9: the reader pool keeps refusing file: identifiers (the ?iiif= parameter is attacker- controlled), and the bridge instead registers each (resource, content path) pair under an unguessable urn:lws-iiif: key through a new code-only trusted-source channel on ImageReaderPoolFactory. The key never leaves the server, so the public /iiif servlet cannot name it; a replaced blob mints a fresh key, so tile/reader caches can never serve stale content; synthetic-scheme validation keeps the channel from ever shadowing PathMapper's http(s) rules or recreating the file: primitive. ImageServer gains a serve(..., source) entry that overrides only where the pixels come from — request shape and info.json id still derive from the request itself. LwsStorageConfiguration installs one bridge across all storages, which is also what flips on the ImageService capability in each storage's description.
…e/tiff) The mirror gateway adopted files with URLConnection's name guess, which has never heard of .svs or .ndpi — so a whole-slide image dropped on disk recorded as application/octet-stream and nothing downstream (the Type Index, the UI's media-binding viewer selection) treated it as imagery. MediaTypeFormats gains the reverse direction — extension -> media type for the specialist formats the readers know (svs/ndpi/tif -> image/tiff, jp2, jxl, dcm, h5, RDF text types) — and the reconciler consults it before the JDK guess. Already-adopted resources keep their recorded type until re-adopted.
A tile request comes from an <img>/viewer fetch the page cannot attach a bearer token to — and publishing the token into the DOM so it could is the C5 hole that was closed. Same answer as the /rdf proxy: the credential stays server-side. A new filter on the storage mappings attaches the session's own token when a request reaches a storage's .iiif endpoint with no Authorization of its own, so ACP decides as that user. Deliberately narrow, because cookie-derived authority is a CSRF surface: GET only, the .iiif endpoint only (a read that changes nothing); every other storage request keeps the pure bearer contract, and a request that brought its own Authorization is never rewritten. Registered for REQUEST and FORWARD dispatch — the global /iiif servlet will forward LWS identifiers here.
…rage The legacy /iiif/?iiif= servlet now detects an image identity inside a configured LWS storage and forwards the request to that storage's own .iiif endpoint — where the ACP decision is made and the session-auth filter supplies the browser's credential. Fixed-prefix viewers (Zephyr) therefore work unchanged for slides living in LWS storage: click an .svs in the container tree, the media bindings open Zephyr, Zephyr asks /iiif, /iiif forwards to the storage, ACP answers as the signed-in user, tiles flow. The bridge's engine call carries an explicit source, so the forward can never re-enter itself. Retires the documented 'known limit' in ZephyrMediaPanel and ui.md; http-api.md records both conveniences and their deliberate narrowness.
Every content-creation path recorded the client's Content-Type verbatim — and a browser uploads .svs as application/octet-stream, because it has never heard of the format. The recorded type is what every media-type-driven consumer keys on (Type Index scanning, the UI's viewer bindings), so a UI-uploaded whole-slide image previewed as 'no viewer bound' and the direct link just downloaded bytes. MediaTypeFormats.recordedMediaType is now the one rule at all four sites (object-store POST and PUT-replace, mirror POST and PUT): the client's SPECIFIC type always wins; an absent/blank/octet-stream type with a known extension records the known format; a replace with no Content-Type keeps the recorded type. Existing resources keep their recorded octet-stream until re-uploaded (or, on the mirror, the file is re-adopted).
A file adopted before the name→type mapping existed keeps its recorded application/octet-stream forever: the reconcile loop only re-adopted on size/mtime drift, and the bytes never changed. The disk is the source of truth in this storage and the type is a fact about the file, so an up-to-date entry whose recorded type is opaque while its name now identifies a known format (.svs → image/tiff) is re-adopted once — fresh type and etag flow to listings and every media-type-driven consumer. Whole pre-existing slide archives correct themselves on the next startup/sweep with no manual re-drop.
…rage A stack seeded from an LWS image now lives WITH its imagery. Zephyr3 mints the new stack's URI beside the seed (the image's container, discovered from the API's rel=up; flat-storage URIs hang off the base with the container stashed in the session), and Save routes any storage-resident stack URI through the LWS API instead of StackStore: first save POSTs the Turtle into the container with the user's own token (ACP authorizes, the storage records ownership, creator is server-stamped as before), later saves are conditional PUTs (a concurrent edit answers 409). Opening a storage-resident stack fetches its Turtle over the API as the signed-in user — the H6 check stays for triple-store stacks; for LWS stacks ACP IS that authority. The last link was the typing: RDFFileReader.getMeta(URI) — the exact overload the LWS metadata scanner calls — threw UnsupportedOperationException, so RDF resources were never typed at all. It now returns the root-focused rdf/dcterms/prov subset, which carries the document's own rdf:type: a saved stack types itself zeph:Stack, listings surface it, the hal:ZephyrStackBinding fires, and the container tree both shows the stack next to its slide and reopens it in Zephyr on click. Sharing comes free: an LWS stack is an ordinary resource under the right-click ACP editor and access requests. Triple-store stacks are untouched and still appear on /stacks.
The viewer modules are plain static files with no content hashing, and the no-cache header guarding against stale copies was applied only in dev mode — so in deployment, browsers' heuristic caching kept serving old modules ACROSS UPGRADES. Diagnosed live: a pre-H1 stackPersistence.js (whose Save posts a SPARQL update to /rdf) kept running from cache long after the endpoint went read-only, so every stack save died with '/rdf request failed: 400' and no server rebuild could fix it. no-cache + must-revalidate costs a conditional 304 per module and ends the trap.
A second context-menu entry opens a confirmation dialog in the existing modal, then issues the LWS DELETE with the entity tag read first (the storage answers 428 to an unconditional delete) and Depth: infinity for containers. ACP stays the storage's decision - a refusal is rendered verbatim. The storage root gets no delete button; the dialog says why. After a successful delete the parent listing is re-read fresh and the preview clears if it showed the deleted item.
An LWS-resident stack is now written the way a web document should be: it references ITSELF as <> and its same-container companions - the imagery and the annotation-layer JSON files - by bare sibling name, with no @base directive. On every read the document inherits the URI it was dereferenced from, so a container can be moved, mirrored or renamed without rewriting the stacks inside it. StackTurtle does the relativization explicitly rather than through Jena's writer-side base mechanism, which also emits ../-hopping and root-relative forms for out-of-container URIs - forms that resolve correctly but rebind wrongly the moment a relative document is copied. Anything outside the stack's container (a cross-container layer, the creator WebID) stays absolute. Round-trip pinned by StackTurtleTest; readers are unchanged because RDFFileReader, loadLwsStack and the browser all already parse with the resource URI as base.
…ionally The relative stack Turtle assumes its annotation-layer JSONs live in the stack's own container, so the writer must honor what the reader assumes: Zephyr3 now injects stackContainer (the minted parent for a new stack, the rel="up" link for a reopened one) and a NEW shape file is born there, not in the image's container - the two only differ for imagery added from elsewhere, which would otherwise strand the JSON outside the container the stack's relative reference points into. Stack-less legacy pages keep the image-container fallback. Re-saving an edited layer now carries If-Match from a fresh HEAD: the LWS storage refuses an unconditional overwrite with 428, so every re-save of an existing annotation file used to land in the failed list. A target with no ETag (legacy LDP) stays unconditional. Also replaces stackPersistence.js's stale pre-H1 header, which still described saving as a SPARQL update to /rdf.
…e once The metadata scanner dispatched by extension through the ServiceLoader provider, which keeps ONE winner per extension - and for ttl that winner is the image-pipeline reader (RDFImageReaderFactory, feature layers rendered as tiles), whose getMeta(URI) throws. Every stored Turtle document therefore scanned as nothing at all: a saved Zephyr stack never surfaced its zeph:Stack type, listings said bare DataResource, and the container tree could only ever show such a file as text. RDF document extensions (ttl, nt, jsonld, rdf) now route straight to RDFFileReaderFactory - the reader that parses with the resource URI as base, which the relative stack documents (<> a zeph:Stack) depend on. The tile pipeline keeps its winner untouched. CURRENT_SCAN_VERSION bumps to 2, so the next start re-reads everything stamped v1 in the background and existing stacks pick up their type with no manual step.
…njunctively The ZephyrStackBinding was rdf:type-only, and type-only bindings are the weakest rank - the defaults' text/* source view outranked it, so even a correctly typed stack.ttl rendered as text. With vandegraph's new conjunctive selectors the binding now requires BOTH the format and the scanner-discovered zeph:Stack type, ranks as an exact media match, and beats the source view - which stays offered as an alternate. Plain or not-yet-scanned Turtle keeps the text view. The old test resolved with a null media type, which is exactly the blind spot that hid this; it now pins the real listing case.
application/x-hdf5 matched no binding at all - not the defaults (image, video, audio, pdf, text, structured text) and not the overlay - so the LWS Containers preview could only say "no viewer is bound" and offer the raw link. The rendering path has existed all along: the storage's .iiif endpoint feeds Halcyon's IIIF engine, whose reader pool picks BeakGraphImageReader for .h5 content, and ZephyrMediaPanel seeds any non-stack resource as layer 0 of a fresh stack, where the .h5 extension makes it a feature layer pulling those tiles. One binding connects the dots, under both recorded spellings of the HDF media type.
AcpSecuredDatasetGraph filtered every find(), getGraph(), listGraphNodes() and the union - and a SPARQL query over it still read forbidden graphs. Two holes, both in the wrapper superclass it extends: stream() delegates straight to the unfiltered base (and query engines use that access path), and nothing marked the wrapper as view-changing, leaving engines free to unwrap it. stream() now routes through the filtered find, and the class implements DatasetGraphWrapperView, the marker that forbids unwrapping. Found by the new AcpSecuredDatasetGraphTest, written because /rdf2 makes this class load-bearing: it pins that the internal graphs (system, ACP, subscriptions, KEYS - one holds a private key) stay invisible even to an allow-everything evaluator, and that an unreadable resource is absent from every access path - enumeration, wildcards, union, and SPARQL.
The query surface for lws-tdb2 that /rdf is for the classic store - but never the raw dataset, which holds the ACP policies, the storage keys and a private key. Every request builds the ACP-secured view for the CALLER (the signed-in session's WebID, else the public agent), so a query answers exactly what that agent's own LWS GETs could fetch and an unauthorized resource is not discoverable at all - the Type Search guarantee, reused. Deliberately a servlet on the app port rather than a Fuseki mount: a static dataset would freeze one agent's view forever, while the ACP evaluator's contract is one instance per request, and the decision must be live. Read-only twice over (H1 discipline): the request must parse as a SPARQL Query, and the dataset is a DatasetGraphReadOnly. Nothing lives in the store's default graph, so it is served as the ACP-filtered UNION of the named graphs - a bare ?s ?p ?o works, and GRAPH ?g enumerates only readable resources. SELECT/ASK answer JSON (or XML/CSV/TSV by Accept), CONSTRUCT/DESCRIBE answer Turtle or N-Triples.
One page, one menu item: /sparql keeps targeting the classic store through /rdf, and /sparql?endpoint=rdf2 targets the W3C LWS store through the new /rdf2 - with an on-page picker to switch, so a second "SPARQL 2" menu entry is unnecessary. The parameter is mapped against an allowlist, never echoed: whatever arrives, the only strings that reach the inline script are the two server-side constants, injected JsSafe via renderHead. Each endpoint gets its own YASGUI persistence namespace, and the current tab is steered to the chosen endpoint on load - a persisted YASGUI tab keeps its own endpoint, so without both, tabs opened against one store would quietly keep querying the other.
The LWS twin of the classic :hasResourceHandler multi-disk layout, except the URI space does not move at all: a mirror storage may declare :hasMount [ :containerPath "tcga/brca" ; :resourceBase <file:///F:/brca/> ] and everything under that sub-container lives on the other disk. Clients see one storage tree; listings, containment, ACP and ETags are untouched, which is why nothing about a mount is ever advertised. MountTable is the one brain: longest-prefix, segment-wise key->root resolution (tcga never claims tcga2; nested mounts shadow the subtree of the mount above, exactly like OS mounts), per-root escape guards, and the walk-root enumeration with the inverse disk->key mapping. The content store resolves every read/write/delete through it - staging files were already siblings of their targets, so atomic replaces stay on the destination volume. The reconciler walks every root, adopts the mount directory itself as its sub-container (synthesizing ancestors a deep mount needs), skips shadowed paths, and treats an OFFLINE mount as unavailable rather than deleted - its subtree stays registered and reappears with the disk. The watcher registers every root; a mount point's directory is never deleted with its container. Flat (uuid) storages refuse mounts: their keys are not paths. Mounts are mirror-only, slug-only, validated leniently (a malformed mount is logged and skipped; the storage stands).
The /user/colorclasses editor (vandegraph SHACLForm over the user's
hal:AnnotationClassList, loaded/saved to their LWS storage as
{userDataStorage}/users/{name}/colorclasses.ttl via LwsCommandNode) was
reachable only from an admin-gated menu link. Color classes are the user's own
per-user document, exactly like the Account page, so the link now shows for
every signed-in user. No page or persistence change -- the LWS save/load was
already in place (4ebd23e).
Builds green: Halcyon 74 tests.
HalcyonSession.getUser() returned the full WebID for a WebID login but the short
preferred_username for a Keycloak login. The Color Classes editor builds
{userDataStorage}/users/{getUser()}/colorclasses.ttl, so for a WebID user the
whole WebID was URL-encoded into one path segment
(users/https%3A%2F%2Febremer.com%2Fid%2Ferich/...) -- the %2F the storage
(Jetty) rejects with HTTP 400, which surfaced as the page's construction error.
Set user = principal.getPreferredUserName() in the WebID branch (its WebID is
already carried by getUserURI()), matching the Keycloak branch. This also makes
the editor agree with the /colorclasses palette relay, which already keys on
getPreferredUserName() -- so both now read/write the SAME document
(users/{shortname}/colorclasses.ttl) instead of two different paths.
getUser() has exactly one caller (ColorClasses), so the semantic alignment is
low-risk.
Builds green: Halcyon 74 tests.
Image layers tile a square power-of-two grid but the display plane is scaled to the actual (non-square) image size, so the outermost tiles cover a cell larger than their real content and ClampToEdgeWrapping stretched their last texel across the overhang -- the stretched right column / bottom row, worst where the image is farthest from a power-of-two multiple of the tile size (e.g. a 166908x84951 SVS). Discard the overhang in the tile shader hook: the map transform puts real content in vMapUv [0,1], so fragments outside that range are the clamp region and now render nothing (the image ends cleanly) instead of a smeared edge texel. Interior tiles have repeat=1 (vMapUv stays in [0,1]) so nothing is discarded; a small epsilon keeps a shared interior edge from dropping a seam pixel to interpolation. JS-only (three.js r160, vMapUv); verify visually in the Zephyr viewer.
Content-Digest and Repr-Digest on GET (binary answered from a stored sha-256, range-aware; JSON/Turtle/linkset over the in-memory bytes), Want-Repr-Digest/Want-Content-Digest negotiation, and inbound Content-Digest verification on writes (a streamed upload is checked against the sha-256 computed while it streamed in, a sha-512 by re-reading the just-stored blob; patch bodies are verified in memory). Want-Content-Digest is advertised on resource responses and OPTIONS, and the storage description now advertises the rfc9530 capability (sha-256, sha-512). Persists the content sha-256 -- wiring the previously-declared LWSX.sha256 -- as a new LwsResource field so binary Repr-Digest needs no blob re-read. Adds DigestFields + DigestFieldsTest; all 160 HalcyonLWS tests pass.
Enumerates every RFC and named specification the Halcyon Project implements in full or in part across its six modules (jena-permissions, halcyon-core, HalcyonLWS, HalcyonLWS-S3, HalcyonMCP, Halcyon), grouped by category with full/partial/vocabulary coverage markers and per-entry evidence. Compiled by scanning source citations, RDF vocabulary namespaces, and declared dependencies.
PATCH on a JSON data resource now accepts application/json-patch+json alongside application/merge-patch+json, applied via jakarta.json Json.createPatch. Per RFC 6902 a failed operation (a test that does not hold, or a remove/replace/move of an absent path) rejects the WHOLE patch with nothing applied -> 409; a body that is not an array is 400; a JSON scalar target is 409. Both formats are advertised via Accept-Patch on JSON data resources (GET/OPTIONS/write responses) and in the storage description PatchSupport capability. Linksets remain merge-patch only. Adds JsonPatchTest (6 tests); all 166 HalcyonLWS tests pass.
It is now supported on JSON data resources (commit b267ea1); remove it from the Notably-NOT-implemented list and add an HTTP row.
…not found) MCP-17 (1decf0e) inserted the top-level halcyon: rate-limit block into the middle of the spring: tree, re-parenting spring.h2/datasource/jpa/ssl under halcyon.*. A fresh install's dumped application.yml therefore set server.ssl.bundle=server without defining the bundle where Spring looks, and the very first boot died with NoSuchSslBundleException. Dev boxes never saw it because INIT only dumps the default when no application.yml exists. Move the halcyon: block below the spring: tree, and drop the unconditional server.ssl block entirely: a fresh box has no halcyonkeystore.jks and its settings.ttl defaults to hal:HTTPS2enabled false, so the connector must come up plain HTTP (previously it also crashed on the missing keystore even with correct nesting). SslConfig puts TLS on the connector when HTTPS2 is enabled; the spring.ssl bundle definition stays for the OIDC client's trust of the server's own certificate, and is registered lazily so the absent keystore is harmless while HTTPS is off. Verified by fresh-booting the rebuilt jar in an empty directory: Wicket home page answers 200 over plain HTTP on :8888.
MyErrorController returned the view names error401/error403/error500, which
exist nowhere in the tree - no template, no static file, no Wicket mount. Every
Spring-dispatched error therefore fell through view resolution into the static
resource handler and surfaced as 404 "No static resource error500", swallowing
deliberate user-facing sendError messages. The WebID login is the worst case:
its precise diagnostics ("dynamic client registration failed at ...") were
replaced by that riddle.
Render a small self-contained HTML page directly (no view resolution), showing
the real status, the sendError/exception message, and the path - both escaped,
since messages echo request parameters. The JSON error path for API clients is
untouched. Also raise WebIdLoginServlet's begin-failure log from debug to warn,
matching the callback servlet - failed logins are operationally significant.
Verified against the rebuilt jar: GET /webid-login with the feature disabled
now answers "404 Not Found / WebID login is not enabled" instead of the masked
"No static resource error500".
root OFF suppressed everything, including Boot's "APPLICATION FAILED TO START" report - every startup problem looked like a silent hang (the SSL-bundle crash and a port-in-use failure both hit this). New defaults: root ERROR everywhere; com.ebremer.halcyon and com.ebremer.lws at INFO for the one-time/operational events an operator needs (service startup, LWS storage mounts, Keycloak key loads, WebID login successes at INFO / failures at WARN); the per-file storage scanner capped to WARN instead of OFF so its errors still surface. The console pattern now carries level and logger like the file pattern. Deps embedded under other com.ebremer packages (beakgraph, vandegraph) stay at root's ERROR.
RFC 9110 15.5.6: the origin server "MUST generate an Allow header field in a 405 response containing a list of the target resource's currently supported methods". None of the eleven refusals did — a client was told "not that method" and never told which ones. Touchstone caught it on POST to a data resource (core/post-to-non-container-405). Problem.methodNotAllowed now takes the Allow list as a required argument, so the omission is a compile error rather than something each call site is free to forget. The lists come from one place: allowFor(kind, resource), which OPTIONS now uses too. They had to agree anyway — a client refused a method and then handed a different list by OPTIONS learns nothing it can act on. Two corrections fall out of sharing it: - The storage root no longer advertises DELETE. It is a container, so the old code listed it, but delete() refuses the root with a 405 — a promise the server would break. - QUERY on a non-queryable resource resolves the resource through the same gate OPTIONS uses, so its Allow is the resource's real list. An agent holding no mode on it is told the resource is not there, exactly as OPTIONS answers, rather than being handed a method list it may not know about.
…fused
An unconditional PUT to a slug (mirror) storage answered 428 and deleted the
resource anyway. Touchstone caught it on vulcan: create, PUT with no If-Match,
then GET the resource that was supposed to have survived -> 500, and the file
was gone for good.
Both halves were in putMirror:
- mirror.writeAt landed the upload ON the live file (atomic move, REPLACE_
EXISTING) before the transaction ran. But the transaction is where If-Match
is compared -- deliberately, so the compare-and-swap happens under TDB2's
single writer -- so by the time the precondition was found missing, the bytes
it guards were already replaced.
- The rollback then called content.delete(key), and in a mirror store the key
IS the path. So the refusal finished by unlinking the resource, leaving a
registered entry pointing at nothing: GET set Content-Length from the stale
size, wrote no body, and Jetty answered 500 until the reconciler caught up.
The mirror store now stages instead: bytes are fsynced into a .tmp-{uuid}
beside the target and published by PathKeyedStore.Staged#publish only after the
metadata transaction commits; close() discards them if it never does. writeAt
stays as stage+publish in one step for callers with nothing to commit in
between (the mount tests, and nothing on a request path).
The same defect was in patchContent and createMirror, and both are fixed the
same way -- a refused JSON Patch had exactly the same effect on the document it
could not patch.
This inverts steps 3 and 4 of the documented write order for this storage
alone. The crash window it opens is the harmless one: committed metadata over
the resource's old bytes, which the reconciler corrects from disk, the
direction it already trusts. architecture.md now says so.
The sharded storages were never affected: they mint a fresh key per write, so
a refused swap only ever leaves an unreferenced blob for the orphan sweep.
lws10-core: a non-empty container deleted without recursion "MUST be rejected with 409 Conflict". Demanding If-Match first made that 409 unreachable for the client most likely to need it. A plain DELETE of a non-empty container answered 428, and a client that dutifully fetched the entity tag and tried again then got the 409 that was true the first time — two round trips to learn one fact. collect() now runs before Preconditions.requireIfMatch, so a request that cannot be honoured whatever entity tag it carries is refused on its own terms. Trees deeper than 256 move ahead of the precondition with it. RFC 9110 13.2.2 states the rule for received preconditions: ignore them when the request would have failed anyway with something other than 2xx or 412. A precondition the server DEMANDS is not literally that case, but the reasoning carries — a conditional is asked for so that a write can be made safe, and this request is not going to write. Neither check mutates anything, so this only changes which refusal is reported; authorization still precedes both, and both still run under the single writer in the one transaction. This does not touch the conditional requirement itself: an unconditional DELETE of an EMPTY resource is still a 428, and a recursive DELETE still needs If-Match. Touchstone's core/delete-non-empty-container-409 gets past the 409 step and then fails on its unconditional Depth: infinity delete, which is that separate question.
lws10-core: POST into a container that does not exist "MUST return a 404 error status unless another status code is more appropriate". Touchstone's core/create-in-missing-container-404 got 401 on vulcan, whose /alpha/ answers anonymous GETs of its root with a listing — that agent was not short of credentials, the container was simply not there. hidden() is the single answer given both for "does not exist" and for "you hold nothing on it", and the two have to stay indistinguishable or the pair is an oracle for walking the URI space. That is unchanged. What changes is which answer anonymous gets: 401 only where a challenge could actually tell it something, which is a storage granting the public nothing. Where the public already has access, authenticating would not change the answer, and 404 is the honest reply. The standing test is taken against the storage ROOT, never the resource in hand. Asked of the resource it would answer 401 for the ones the public may not see and 404 for the ones that were never there — the very distinction hidden() exists to erase, rebuilt one level down. Asked of the root it is the same answer for every URI in the storage, so it discloses nothing about any of them. A storage seeded by AcpBootstrap grants anonymous nothing and so still challenges, which is what auth-oidc/anonymous-request-401-challenge pins. A storage that opens only a sub-container to the public still challenges as well — the root is the whole test — which is narrower than the spec would like, but it errs toward the challenge rather than toward disclosure. This also unblocks the closing steps of core/delete-resource-updates-parent and core/delete-non-empty-container-409, which both end by GETting a resource they deleted and expecting 404. Both still fail earlier, on DELETE's demand for If-Match, which is a separate question.
…es it The Keycloak stack could not be turned off. :AuthServer had no off position — absent, it fell back to http://localhost:8888, this server's own address rather than an authorization server's, so a settings file that never mentioned it still pointed the OIDC client and the bearer verifier at Halcyon itself and let them fail at discovery. That is not a disabled subsystem, it is a broken one. :AuthServer is now the switch. Present, everything is exactly as it was; commented out, isKeycloakEnabled() is false and nothing Keycloak-shaped is constructed: - the pac4j KeycloakOidcClient, its Config, the /callback and logout filters, and the security filters over getSecuredURLs()/getAdminURLs() (Cool, HalcyonConfigFactory, Main) — the Wicket pages fall to HalcyonAuthorizationStrategy alone, off the WebID the interactive login seats, which is what it already does for a WebID session when Keycloak IS running; - the /auth/* reverse proxy, unmounted rather than left to 502; - BearerTokenVerifier in every storage's CredentialChain. That last one is the reason this has to skip CONSTRUCTION and not merely refuse tokens. The verifier performs OIDC discovery in its constructor, so building one against a Keycloak that is not running hangs each storage's startup on a connection nobody will accept. The gate is @conditional(KeycloakEnabled.class), reading the HalcyonSettings singleton — loaded from settings.ttl by INIT.init() in main(), before the context is built, so it is already there to be asked. It logs once which stack is live. The challenge's as_uri becomes {site}/webid-login when Keycloak is off. It is REQUIRED and there is no single authorization server to name: under WebID-OIDC the issuer is whichever OP the agent's own WebID nominates, unknowable until one is presented. The interactive login is the one URI on this host that takes an agent from no credential to a credential. Four UI affordances go with it — the Login menu item, the Account page's Keycloak console link, HalcyonSession's Keycloak admin REST block (already unreachable without a pac4j profile; now it says so), and LogoutLink, which redirected to the end-session endpoint under the unmounted /auth and would have landed the user on a 404 having genuinely logged them out. With both Keycloak and lws-oidc.json off the chain is empty: PUBLIC for a request with no credential, invalid_token for one with. An unconfigured server authenticates nobody rather than everybody. Nothing is deleted. Restoring the :AuthServer line brings all of it back.
Every DELETE was answered 428 until the client had first fetched the resource for its entity tag. Touchstone failed two MUSTs on it — core/delete-resource-updates-parent on the plain delete, core/delete-non-empty-container-409 on the Depth: infinity one — and they were the only two failures left in the core suite. lws10-core mandates the 428 in exactly two places: an unconditional PUT on an ETagged resource (put-unconditional-rejected-428), and a PUT or PATCH on a linkset (linkset-if-match-412-428). Of DELETE it says only "on success, the server MUST respond with 204 No Content. Servers SHOULD support conditional requests, as defined in [RFC9110]." SHOULD *support* is an obligation to honour a validator that arrives, not a licence to require one, and reading it as a requirement inverted the MUST beside it: the 204 the spec demands on success was unreachable, because success was. The tell was in Preconditions' own javadoc, which quoted the PUT clause as the justification for a rule it was applying to DELETE as well. checkIfMatch is the honour-if-offered form; requireIfMatch now delegates to it after its own absence check, so the comparison keeps a single definition and PUT is untouched. The DELETE path is the only call site that moved: put, commitMirrorPut, putAcr and patchLinkset all still demand the conditional, and core/put-unconditional-428 still passes. The compare-and-swap is undiminished for anyone who wants it. A stale tag is still refused 412, still compared inside the write transaction, still under TDB2's single writer. What changes is that not sending one is no longer an error — which was always the client's call to make. This also lets the comment above collect() say what it means. It used to note that RFC 9110 13.2.2 "is not literally that case" because the server DEMANDED the precondition; with the demand gone, a 409 that will be returned whatever the validator says is literally the case 13.2.2 describes. docs/lws/http-api.md now separates the two: required on PUT and the linkset/ACR writes, optional on DELETE.
LwsResource gained a 14th component, sha256, but the two factory helpers in this test still pass 13 arguments, so the module fails testCompile: constructor LwsResource in record com.ebremer.lws.store.LwsResource cannot be applied to given types reason: actual and formal argument lists differ in length A record declares exactly one canonical constructor and this one has no overload, so there is no arity the 13-argument call could satisfy. Both helpers describe resources with no content digest, so the added argument is null, matching the component's documented meaning (null for containers, and for a data resource whose digest was never computed). This went unnoticed because ci.yml triggers only on master and develop, while all development lands on next.
Claude Code writes settings.local.json as the machine-local counterpart to a shared settings.json: it holds the commands approved on this machine. This one carries a Windows user path, the absolute path of a separate repository, localhost probe URLs and blanket grants such as Bash(curl *) and Bash(git rm *), none of which should follow a clone. Ignoring it rather than committing it matches how the repository already treats CLAUDE.md, settings.ttl and lws-oidc.json. A shared .claude/settings.json, if one is ever added, remains tracked.
The workflow watched only master and develop. All current work is on next, which was 127 commits ahead of master and built by nothing, so every branch this file covered was dormant and CI proved nothing. The gap was not theoretical. BeakGraphQueryCapabilityTest kept calling the 13-argument LwsResource constructor after the record gained a 14th component (sha256); that testCompile failure sat on next until it was found by hand. A workflow that had been watching the branch would have caught it on the push that introduced it. master and develop stay listed: this adds coverage rather than moving it.
XMP packets arrive inside uploaded images, so the XML XMP.getXMP parses
is attacker-controlled. Both parse sites built a stock
DocumentBuilderFactory with only setNamespaceAware(true), so an external
general entity was resolved and inlined into the //rdf:RDF subtree --
which getXMP then re-serialises and asserts as triples on the resource's
own graph, readable by whoever uploaded it.
Reproduced end to end against this class before the fix. A packet
carrying
<!DOCTYPE r [ <!ENTITY xxe SYSTEM "file:///path/canary"> ]>
... <dc:title>&xxe;</dc:title>
produced exactly one triple whose object was the file's contents:
[http://victim/store/bomb.tif, dc:title, SECRET-CANARY-...]
The reachable path is an ordinary upload: LwsServlet create ->
LwsMetadataScanner.enrichAsync -> TiffImageReader.getMeta reads TIFF tag
700 and hands the bytes to XMP.getXMP, and ResourceRegistry
.addDiscoveredTypes merges the result into the resource's named graph. So
any file the server process can read -- settings.ttl, keystores, other
tenants' TDB2 files -- was retrievable by uploading a crafted TIFF, and an
http: entity gave SSRF to internal hosts.
Both DocumentBuilderFactory sites now come from
JenaXMLInput.newDocumentBuilderFactory(), which is the hardening Jena
already applies to its own parsers, so the two cannot drift apart. Layered
behaviour, each measured:
stock factory (before) entity resolved -> LEAKS
JenaXMLInput alone SAXParseException: 'file' access is not
allowed due to accessExternalDTD
JenaXMLInput + doctype refused SAXParseException: DOCTYPE is disallowed
JenaXMLInput alone closes it; disallowing the doctype rejects a packet
earlier still and is safe because an XMP packet never needs a DTD. The
two TransformerFactory sites that re-serialise the extracted node also
get ACCESS_EXTERNAL_DTD/STYLESHEET cleared, guarded because not every
implementation knows those attributes.
Verified after the fix: the malicious packet yields no triples at all,
and a benign XMP packet still parses to its dc:title.
The engine every LWS authorization passes through was covered by exactly
one policy shape -- a single anyOf matcher granting Read, inherited from
LwsOidcAcpIssuerTest, whose subject is the OIDC issuer chain rather than
the engine. Deny, the allOf/noneOf branches, the inheritance asymmetry,
the fail-closed guards and the time-boxing were all untested, so any of
them could have been deleted or inverted without turning the build red.
That is the wrong safety net to hold while refactoring an authorization
engine, and Theme C of the review proposes doing exactly that.
41 tests over the real engine and a real (temp) TDB2 LwsStore, opened
reflectively as LwsOidcAcpIssuerTest already does. Nothing on the
decision path is mocked. Grouped as: fail-closed guards, matcher
combination (allOf/anyOf/noneOf and their ANDing), deny-beats-allow
including the Write=>Append implication and the append-only inbox,
agent matching (Public/Authenticated/Creator/Owner/WebID), the
client/issuer/vc context attributes, inheritance, time-boxed grants,
and memoisation.
Two of these carry properties a plausible refactor destroys, so both
were mutation-checked rather than merely observed to pass:
removing the no-matcher guard in satisfied()
-> aPolicyWithNoMatchersGrantsNobodyNotEveryone fails,
expected <[]> but was <[READ]> for the ANONYMOUS agent
making the ancestor walk follow accessControl instead of
memberAccessControl
-> 4 Inheritance tests fail, including
anAncestorsOwnAccessControlMustNotReachTheChild
Two deliberate choices. The memoisation test pins a trap rather than a
feature: the memo is keyed by resource URI alone, so one engine reused
across agents hands the second agent the first's decision. That is why
every write path builds a fresh AcpEngine, and the test records the
reason so the discipline can be relaxed deliberately rather than by
accident. And aNonLiteralBoundThrowsInsteadOfFailingClosed_knownDefectF058
pins current behaviour, not correct behaviour: a non-literal
schema:expires throws LiteralRequiredException instead of failing
closed. Asserting the correct result would make the build red; omitting
it would lose the defect. The test says what to change it to when F058
is fixed.
Five entry points let a caller make this server issue an outbound request,
each guarded differently or not at all: the store-wide /rdf2 endpoint, the
per-resource BeakGraph surface (every .h5 resource URL), the MCP
sparql_query tool, and webhook subscription inboxes. They now share one
rule.
The MCP federation ban was bypassable. Its check walked the parsed
Element tree, which never enters an expression, so a SERVICE nested in
FILTER EXISTS / FILTER NOT EXISTS / BIND(EXISTS{...}) was invisible to
it. Measured across ten query shapes before changing anything: those
three passed straight through while plain, subquery, OPTIONAL, MINUS,
UNION and GRAPH nesting were all caught -- the ban read as working while
being one keyword from bypass, which is the worst state for a security
check. SparqlGuard compiles to the algebra and walks it with an
expression visitor, which puts every SERVICE in one place.
The HTTP endpoints deliberately do NOT ban SERVICE. Commit f4550da added
ServiceHttpClient precisely so a SERVICE back to this server's own origin
completes over its self-signed certificate -- every LWS resource is a
SPARQL endpoint there, so federation is a feature, and banning it would
have deleted working behaviour to fix a different problem. Instead every
SERVICE target goes through the SsrfGuard the OIDC fetches already use,
so loopback, private, link-local and cloud-metadata addresses are
refused while ordinary federation continues. A SERVICE with a variable
endpoint is refused: its target is not knowable until bindings flow,
which is after the point where it could be checked.
The allow-list is read from server configuration only -- the
deployment's :ProxyHostName plus lws-oidc.json's allowedInternalHosts,
the same list the OIDC fetches honour. Deliberately not from the request:
deriving "this server's own host" from the Host header would let a caller
send Host: 169.254.169.254 and thereby allow-list the metadata endpoint,
so the guard would wave through the exact request it exists to stop.
Notifications.subscribe now checks the webhook inbox the same way. An
unchecked inbox is an SSRF that fires on every matching event; it is
refused at subscribe time so the subscriber learns immediately rather
than silently never being called.
Closes F016, F014, C34, C23, F076. Not addressed here: F141 (Zephyr
save.js PUTs to a URL named in stack RDF) and F154 (a Wicket panel acts
on an arbitrary uri parameter) -- both are filed under the same theme but
are client-side and UI-authorization concerns, not this egress path.
51 tests: the nesting table above as a parameterized case per shape, the
internal-address refusals as IP literals so nothing depends on DNS, the
allow-listed-host path that keeps self-federation working, and the
:ProxyHostName parsing -- where a first cut mistook a bracketed IPv6
literal's colons for a port and returned "[::1]:8888", a host string
matching nothing, which would have dropped an IPv6 deployment from its
own allow-list with no error.
All ACP data for a storage shares one graph, and {resource}.acr is a
client-writable window onto it. Four ways that scope leaked.
AcrStore.replace required the submitted ACR to declare its target but
never forbade it declaring others. AcpEngine resolves a resource's ACRs
by acp:resource alone -- it does not require the subject to be typed, or
to be named {resource}.acr -- so one extra object on that property, or
one on any node hung off the ACR, aimed the submitter's policies at a
resource they were never given Control over. Every acp:resource triple
must now be exactly the one claim the ACR is entitled to make, and
nothing may declare a second AccessControlResource in the same write.
(F009.)
replace also spliced the submitted model in wholesale, whatever its
subjects were, so a PUT could carry a triple about ANOTHER resource's
ACR node and edit policy it does not control, or leave detached policy
behind to surface later. Every triple must now be reachable from the ACR
node -- the same rule read() uses to carve the ACR back out, so anything
it would refuse to serve it also refuses to store. (F010.)
purge reached only {resource}.acr. An access grant installs its policy
under its own node (urn:lws:grantacr:{id}-{n}) carrying acp:resource
<target>, and that separation is deliberate: it is what lets a grant
survive the owner rewriting their own ACR. On delete it was a leak --
the grant outlived the resource, and under the slug naming policy the
URI is reused, so re-creating the same name inherited a stranger's
grant. Delete now purges every ACR targeting the resource, one at a
time so the existing reference count still protects nodes shared with a
survivor. (F059.)
Revoking a grant removed the graph REACHABLE from the grant's ACR node,
and that walk went through the matcher's acp:agent object -- a URI the
requester supplies as the assignee. Naming the storage root's ACR there
and then deleting one's own grant took the root's whole policy tree with
it: authorization for the entire storage destroyed by an authenticated
agent with Control over a single resource of their own. Reachability was
never the right rule here; installPolicy mints a closed node set and its
javadoc already promised revocation "removes exactly this and nothing
else". Enumerating those four nodes keeps that promise by construction,
so nothing a client writes can widen what a revocation deletes.
MAX_CLOSURE_HOPS and three imports go with the walk. (F061.)
Evidence rather than assertion. AcrStoreTest failed 4 of 9 against the
unfixed code -- one failure per defect above -- and passes 9 of 9 after.
The revocation fix was mutation-checked separately: restoring the
reachability walk fails revokingAGrantCannotDeleteTheStorageRootsPolicy
with "expected: <[READ]> but was: <[]>", the root policy gone.
AcpEngineTest (41) and SparqlGuardTest (32) stayed green throughout,
which is why those went in first. Full verify clean: HalcyonLWS 255,
Halcyon 85, HalcyonMCP 70, no failures anywhere -- the tightened
validation refuses nothing the codebase legitimately writes.
The pac4j filter guarding the IIIF servlet was registered on "/iiif*/". That is not a legal servlet mapping -- a glob may only sit at the end of a prefix spec -- and it never said so, because of an asymmetry in Jetty: a SERVLET mapping is parsed through ServletPathSpec's constructor, which rejects an illegal spec at startup, but a FILTER mapping is kept as a raw String and matched with the static ServletPathSpec.match. So the illegal pattern was accepted silently and degraded to an exact match on its own seven literal characters, which no request path can be. ImageServer at "/iiif/*" was unguarded, with no error, no warning and no log line at any point. "/f*" was dead the same way (Jetty parses it as EXACT and only warns, from a constructor filters never reach), and "/invalidateSession" was legal but covered nothing under the "/invalidateSession/*" its servlet is actually mounted on. Two changes, because either alone leaves the hole. UrlPatternRules + UrlMappingAudit refuse to start on a pattern that cannot do its job: assertLegal for the form, and assertCovers for the case legality cannot catch -- a pattern that names a servlet's URL space without covering it. Both throw rather than warn. The defect being prevented produced no observable signal for however long it existed, so a log line would simply join the same silence; a server that cannot enforce the access control it was configured with should not accept traffic. UrlMappingAudit is deliberately NOT @conditional: every bean in Cool is @conditional(KeycloakEnabled) and the checked-in settings.ttl declares no :AuthServer, so a check hosted beside the filters would never run in the configuration this project ships. ImageServer's classic path now resolves the session itself and answers 401 without one. The filter cannot be the only gate precisely because it is conditional on Keycloak, and with the shipped settings it does not exist at all. The check sits AFTER the LWS forward and is skipped when source != null: those are the bridge re-entering once LwsServlet has run storage confinement and demandOn(acl:Read), and the forward handing an LWS identifier to the owning storage's .iiif capability, whose ACP decision may legitimately permit an anonymous reader. Requiring a session there would override a storage's own policy. The patterns themselves become "/iiif/*" and "/invalidateSession/*", and the dead "/f*" is removed. Closes F008 and the C24 dead/under-covering entries. 20 tests over the rules, including the exact strings that failed. Full verify clean across all six modules -- which is the check that mattered here, since the audit now runs against every registered pattern in the application at every boot, not just the two lists corrected above.
aea06e7 shipped two defects that no test in this repository could see, and both were found by launching the server. First, the @bean method inside UrlMappingAudit was named after its own enclosing class. A @configuration class is registered under its decapitalised simple name, so 'urlMappingAudit' was defined twice and the context refused to refresh. Renamed to securityUrlPatternAudit. Second, and worse in principle: the audit applied its secured-URL rule -- "a pattern must not cover the whole application" -- to every servlet registration it could see, including Spring's own dispatcherServlet, which is correctly mapped on "/". A change meant to make the server refuse unsafe configuration instead made it refuse a correct one. Those are two different questions and they now have two methods. assertLegal checks FORM only, where "/" and "/*" are legal and are what a dispatcher and a framework filter are normally registered on; assertGuardable adds the strict rules and is applied only to the lists this project maintains, URLControl.getSecuredURLs() and getAdminURLs(). The reason both reached a real launch is worth recording, because it is not specific to this class: nothing in this repository starts a Spring context. The full 29,000-test verify passed before and after aea06e7 and could not have caught either defect -- a suite that exercises logic exhaustively and wiring not at all. UrlMappingAuditContextTest is the first test here that refreshes a context. It covers both shapes: a refresh that must succeed, and a registration set containing a dispatcherServlet on "/" alongside an imageServer on "/iiif/*". Deliberately not @SpringBootTest -- that would start Jetty, TDB2 and the LWS storages; registering the one configuration class catches the whole class of defect in milliseconds. It also caught a third defect, in the test: asserting "exactly one SmartInitializingSingleton" counted Spring's own EventListenerMethodProcessor, which implements that interface. The assertion now names the bean it means instead of counting the type.
Jena 6.2.0 reorganised its test artifact, so the version bump and the migration have to land together -- the bump alone does not compile. Every main source in all six modules builds against 6.2.0 unchanged, which is the result that matters for a vendored jena-permissions: no Jena interface grew a method the fork would have to wrap, and SecuredItemInvokerCoverageTest -- the build-time reflection sweep over the thirteen proxied types, which exists precisely to turn that event into a build failure rather than a runtime surprise -- passes. What moved, all of it in the jena-core tests jar: org.apache.jena.graph.test.* -> org.apache.jena.graph.* org.apache.jena.rdf.model.test.* -> org.apache.jena.rdf.model.* org.apache.jena.rdf.model.test.helpers.* -> org.apache.jena.rdf.model.helpers.* TestingModelFactory -> ModelCreator TS3_Model1.PlainModelFactory -> deleted; ModelCreator.plain AbstractTestPackage -> concrete, takes a ModelCreator The interface change needed a judgement rather than a rename. TestingModelFactory declared createModel(), getPrefixMapping() and createModel(Graph); ModelCreator extends Creator<Model> and declares only create(). Both dropped methods were checked before deleting them, because this is the security fork and a quietly-removed method is exactly how a test stops testing anything: getPrefixMapping() delegated straight to the model it creates, and createModel(Graph) wrapped a caller-supplied graph WITHOUT securing it. Neither did security work. The secured construction in create() is unchanged and is the only load-bearing part. Spring AI 2.0.0 -> 2.0.1 rides along; the pom's own policy asks for a review on a minor or major, and this is a patch. Full reactor verify clean: 29,518 tests, no failures, across all seven modules. jena-permissions itself runs 29,040, up 54 from 6.1.0 because the migrated upstream classes contribute more cases. Not fixed here, and pre-existing: SecTestLiterals, SecTestReaders and SecTestReaderEvents pass a PLAIN, unsecured model creator, so they run upstream's suite against an ordinary model and exercise none of this fork's security layer. That was true before the migration (TS3_Model1.PlainModelFactory was equally plain) and preserving it was deliberate -- changing what a test tests does not belong in a build fix. It is the same shape as review finding F101/F167.
…ng it 24e539f refused SERVICE with a variable endpoint outright, on the reasoning that its target is unknown until bindings flow and so cannot be checked before the request is made. That was safe and wrong. It broke a legitimate query: select distinct ?g where { graph ?g { ?s as:mediaType "application/x-hdf5" } service ?g { ?sx exif:width ?w ; exif:height ?h } } which answered 400. Every LWS resource on this server IS a SPARQL endpoint, so "graph ?g { ... } service ?g { ... }" is the natural way to ask one question of every matching resource -- the same capability f4550da added ServiceHttpClient to support. A blanket refusal deleted a working feature to close a hole that can be closed properly. The target is knowable, just later than the static check looks. Jena's service executor chain hands a ChainingServiceExecutor both the original OpService and the SUBSTITUTED one, with the variable already resolved against the current binding, immediately before the request is made. SparqlServiceEgressExecutor checks there, against the same SsrfGuard and the same allow-list a constant endpoint gets, and is installed at startup beside ServiceHttpClient. This is strictly better than what it replaces, not merely less strict: it runs once per resolved target rather than once per query, and it catches an endpoint derived through VALUES or a sub-select, which no static check of the query text could see. SparqlGuard.checkEgress now passes over a null target rather than throwing, and says why; serviceTargets still reports it as null and federates() still counts it, so the MCP tool ban (refuseFederation) refuses a variable endpoint exactly as before. The two halves must ship together: without the executor installed, a variable endpoint is unchecked. Both files say so. SparqlGuardTest gains the graph-then-service idiom that regressed and replaces the assertion that pinned the wrong behaviour. 33 tests there; full reactor verify clean at 29,518.
as: is what the LWS media-type triples use (as:mediaType), so it belongs in the editor's prefix list alongside sdo:, hal: and rdfs:.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.