Skip to content

Commit 7c0c6e9

Browse files
authored
Merge pull request #1044 from silk-framework/feature/hashing-CMEM-7596
CMEM-7596: Add per-value hash operator
2 parents acbcced + 890f518 commit 7c0c6e9

8 files changed

Lines changed: 358 additions & 44 deletions

File tree

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,29 @@
1-
Calculates the hash sum of the input values. Generates a single hash sum for all input values combined.
2-
This operator supports using different hash algorithms from the [Secure Hash Algorithms family](https://en.wikipedia.org/wiki/Secure_Hash_Algorithms) (SHA, e.g. SHA256) and two algorithms from the [Message-Digest Algorithm family](https://en.wikipedia.org/wiki/MD5) (MD2 / MD5). Please be aware that some of these algorithms are not secure due the possibility of collision attacks and other attacks.
1+
The **Combined input hash** operator produces exactly one hash value covering all input values combined, across all connected input ports. However many values arrive and however many ports are connected, the output is always a single string.
2+
3+
## How combining works
4+
5+
All values from all input ports are fed sequentially into a single hash function — port 1 first, then port 2, and so on. Within each port, values are processed in the order they arrive. No separator is inserted between values or between ports. The hash covers the concatenated byte content of all values in that traversal order.
6+
7+
This means the result depends on both the content and the order of values. The same set of values in a different order produces a different hash. Connecting one port with values `["apple", "banana"]` produces the same hash as connecting two ports with `["apple"]` and `["banana"]` respectively, because the bytes are fed in the same sequence either way.
8+
9+
## Output
10+
11+
The output is a single lowercase hexadecimal string. The length depends on the algorithm: 64 characters for SHA-256, 32 for MD5, 40 for SHA-1, 96 for SHA-384, 128 for SHA-512. If the input is empty, the output is the hash of an empty message.
12+
13+
Values are encoded as UTF-8 before hashing.
14+
15+
## Algorithm parameter
16+
17+
The algorithm parameter selects the hash function. The default is SHA-256. The following algorithms from the [SPARQL 1.1 specification](https://www.w3.org/TR/sparql11-query/#func-hash) are supported:
18+
19+
| SPARQL name | Java name | Notes |
20+
|-------------|-----------|-------|
21+
| MD5 | MD5 | Weak — vulnerable to collision attacks. Avoid for security-sensitive use. |
22+
| SHA1 | SHA-1 | Weak — deprecated for most security purposes. |
23+
| SHA256 | SHA-256 | Recommended default. |
24+
| SHA384 | SHA-384 | Stronger than SHA-256. |
25+
| SHA512 | SHA-512 | Strongest in the SPARQL set. |
26+
27+
Additional algorithms available on the JVM (such as SHA-512/256 and SHA-3 variants) are also accepted. The full list is JVM-dependent and visible in the algorithm parameter dropdown.
28+
29+
Note that the Java names use hyphens (SHA-256, SHA-1) where SPARQL uses none (SHA256, SHA1). Both forms are accepted by this operator.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
The **Per-value hash** operator hashes each input value independently and returns one hash per value. The output count always equals the input count — cardinality is preserved.
2+
3+
## SPARQL alignment
4+
5+
This operator produces the same output as the SPARQL 1.1 hash functions applied per value. For a single input value, `SHA256(?x)` in SPARQL returns the same result as this operator with the default SHA256 algorithm.
6+
7+
## Single-input constraint
8+
9+
The operator accepts exactly one input port. Connecting more than one port throws an `IllegalArgumentException`. This constraint exists because per-value hashing is defined relative to a single value sequence — combining values across ports would require choosing a port-merging strategy, which is the behaviour of the **Combined input hash** operator instead.
10+
11+
## Output
12+
13+
Each input value produces one lowercase hexadecimal hash string. The output order matches the input order. If the input is empty, the output is empty — no hash is produced.
14+
15+
Values are encoded as UTF-8 before hashing.
16+
17+
## Algorithm parameter
18+
19+
The algorithm parameter selects the hash function. The default is SHA-256. The five algorithms from the [SPARQL 1.1 specification](https://www.w3.org/TR/sparql11-query/#func-hash) are supported:
20+
21+
| SPARQL name | Java name | Notes |
22+
|-------------|-----------|-------|
23+
| MD5 | MD5 | Weak — vulnerable to collision attacks. Avoid for security-sensitive use. |
24+
| SHA1 | SHA-1 | Weak — deprecated for most security purposes. |
25+
| SHA256 | SHA-256 | Recommended default. |
26+
| SHA384 | SHA-384 | Stronger than SHA-256. |
27+
| SHA512 | SHA-512 | Strongest in the SPARQL set. |
28+
29+
Additional algorithms available on the JVM are also accepted. The full list is JVM-dependent and visible in the algorithm parameter dropdown.
30+
31+
Note that the Java names use hyphens (SHA-256, SHA-1) where SPARQL uses none (SHA256, SHA1). Both forms are accepted by this operator.
32+
33+
## Contrast with Combined input hash
34+
35+
The **Combined input hash** operator feeds all values from all ports into a single hash function and returns one hash regardless of input size. Use it when you need a single fingerprint for a set of values taken together.
36+
37+
Use **Per-value hash** when each value needs its own hash — for example, to hash a column of URIs independently, or to replicate `SHA256(?x)` in SPARQL.

silk-rules/src/main/scala/org/silkframework/rule/plugins/RulePlugins.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ class RulePlugins extends PluginModule {
115115
// Dataset
116116
classOf[FileHashTransformer] ::
117117
classOf[InputHashTransformer] ::
118+
classOf[PerValueHashTransformer] ::
118119
// Numeric
119120
classOf[NumReduceTransformer] ::
120121
classOf[NumOperationTransformer] ::
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.silkframework.rule.plugins.transformer.value
2+
3+
import org.silkframework.runtime.plugin.{AutoCompletionResult, ParamValue, PluginContext, PluginParameterAutoCompletionProvider}
4+
import org.silkframework.workspace.WorkspaceReadTrait
5+
6+
import java.security.Security
7+
import scala.jdk.CollectionConverters.IterableHasAsScala
8+
9+
/**
10+
* Provides autocomplete suggestions for hash algorithm names.
11+
*
12+
* The algorithm list is sourced from the JVM's registered security providers via [[java.security.Security#getAlgorithms]],
13+
* scoped to the `MessageDigest` service type. The result is JVM-dependent — different provider configurations
14+
* return different sets. The list is built once on first access and cached.
15+
*/
16+
case class HashAlgorithmAutoCompletionProvider() extends PluginParameterAutoCompletionProvider {
17+
18+
private lazy val algorithms: Seq[String] = Security.getAlgorithms("MessageDigest").asScala.toSeq.sorted
19+
20+
override def autoComplete(searchQuery: String, dependOnParameterValues: Seq[ParamValue],
21+
workspace: WorkspaceReadTrait)
22+
(implicit context: PluginContext): Iterable[AutoCompletionResult] = {
23+
val multiSearchWords = extractSearchTerms(searchQuery)
24+
algorithms
25+
.filter(r => matchesSearchTerm(multiSearchWords, r.toLowerCase))
26+
.map(r => AutoCompletionResult(r, None))
27+
}
28+
29+
override def valueToLabel(value: String, dependOnParameterValues: Seq[ParamValue],
30+
workspace: WorkspaceReadTrait)
31+
(implicit context: PluginContext): Option[String] = None
32+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.silkframework.rule.plugins.transformer.value
2+
3+
import org.silkframework.rule.input.InlineTransformer
4+
5+
import java.nio.charset.StandardCharsets
6+
import java.security.MessageDigest
7+
import java.util.HexFormat
8+
9+
/**
10+
* Digest operations for hash transformer plugins.
11+
*
12+
* Provides [[toHex]], [[hashValue]], and [[withDigest]] as concrete implementations over the abstract
13+
* [[algorithm]] parameter, which each concrete plugin supplies via its own plugin parameter. Subclasses
14+
* implement [[apply]] according to their combining or per-value semantics:
15+
* - [[hashValue]] is suited to per-value operators: it is a complete, self-contained operation on a
16+
* single string.
17+
* - [[withDigest]] is suited to combining operators: it loans a reset digest for the duration of a
18+
* single lambda, leaving the caller responsible for updates and finalization within that scope.
19+
*/
20+
trait HashTransformer extends InlineTransformer {
21+
/** The hash algorithm name, passed directly to [[java.security.MessageDigest#getInstance]].
22+
* Must be recognized by the JVM's security provider registry; unrecognized names cause
23+
* [[java.security.NoSuchAlgorithmException]] at runtime. */
24+
def algorithm: String
25+
26+
// @transient: HexFormat is not serializable. lazy: transient fields are null after deserialization;
27+
// lazy ensures the instance is recreated on first use rather than remaining null.
28+
@transient private lazy val hexFormat: HexFormat = HexFormat.of()
29+
30+
// @transient: not serialized. lazy: recreated on first use after deserialization.
31+
@transient private lazy val threadLocalDigest: ThreadLocal[MessageDigest] =
32+
ThreadLocal.withInitial(() => MessageDigest.getInstance(algorithm))
33+
34+
/** Encodes a byte array as a fixed-length lowercase hex string. Each byte maps to exactly two
35+
* characters; without zero-padding, a byte with value 5 would render as "5" instead of "05",
36+
* producing a variable-length string that breaks any downstream comparison or length check. */
37+
def toHex(bytes: Array[Byte]): String =
38+
hexFormat.formatHex(bytes)
39+
40+
/** Updates the digest with the UTF-8 encoding of [[v]]. */
41+
def updateWith(digest: MessageDigest, v: String): Unit =
42+
digest.update(v.getBytes(StandardCharsets.UTF_8))
43+
44+
/** Hashes a single string value and returns its hex digest. A thread-local
45+
* [[java.security.MessageDigest]] instance is reused across calls on the same thread:
46+
* [[java.security.MessageDigest]] is stateful and not thread-safe, so each thread maintains
47+
* its own instance rather than allocating a new one per call. */
48+
def hashValue(v: String): String =
49+
withDigest { digest =>
50+
updateWith(digest, v)
51+
toHex(digest.digest())
52+
}
53+
54+
/** Resets the thread-local [[java.security.MessageDigest]] and loans it to the given function.
55+
* The instance is shared on the calling thread; do not retain a reference beyond the function's scope. */
56+
def withDigest[A](f: MessageDigest => A): A = {
57+
val digest = threadLocalDigest.get()
58+
digest.reset()
59+
f(digest)
60+
}
61+
}

silk-rules/src/main/scala/org/silkframework/rule/plugins/transformer/value/InputHashTransformer.scala

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,113 @@
11
package org.silkframework.rule.plugins.transformer.value
22

33
import org.silkframework.rule.annotations.{TransformExample, TransformExamples}
4-
import org.silkframework.rule.input.InlineTransformer
54
import org.silkframework.rule.plugins.transformer.replace.MapTransformerWithDefaultInput
65
import org.silkframework.runtime.plugin.annotations.{Param, Plugin, PluginReference}
7-
import org.silkframework.runtime.plugin.{AutoCompletionResult, ParamValue, PluginContext, PluginParameterAutoCompletionProvider}
8-
import org.silkframework.workspace.WorkspaceReadTrait
96

10-
import java.nio.charset.StandardCharsets
11-
import java.security.{MessageDigest, Security}
12-
import scala.jdk.CollectionConverters.IterableHasAsScala
7+
import java.security.MessageDigest
138

9+
10+
/**
11+
* Combines all input values across all connected ports into a single hash.
12+
*
13+
* Values are fed sequentially into a single [[java.security.MessageDigest]] instance — port 1 first, then port 2,
14+
* and so on; within each port, values are processed in order. No separator is inserted between values or ports.
15+
* The output is always exactly one lowercase hexadecimal string, regardless of how many values or ports are provided.
16+
*
17+
* @see [[PerValueHashTransformer]] for the per-value variant that produces one hash per input value.
18+
*/
1419
@Plugin(
1520
id = InputHashTransformer.pluginId,
1621
categories = Array("Value"),
17-
label = "Input hash",
18-
description = """Calculates the hash sum of the input values. Generates a single hash sum for all input values combined.""",
22+
label = "Combined input hash",
23+
description = """Calculates a single hash value covering all input values combined, across all input ports. Values are fed into the hash function in port order without any separator between them.""",
1924
documentationFile = "InputHashTransformer.md",
2025
relatedPlugins = Array(
26+
new PluginReference(
27+
id = PerValueHashTransformer.pluginId,
28+
description = "The Per-value hash plugin hashes each input value independently and returns one hash per value, preserving cardinality. The Combined input hash plugin instead feeds all values into a single hash function, producing one combined hash regardless of input size."
29+
),
2130
new PluginReference(
2231
id = MapTransformerWithDefaultInput.pluginId,
23-
description = "One hash value is produced for the entire set of inputs by the Input hash plugin. The Map with default plugin instead keeps a value sequence and rewrites it position by position through the mapping, falling back to the second input where no mapping entry is found."
32+
description = "One hash value is produced for the entire set of inputs by the Combined input hash plugin. The Map with default plugin instead keeps a value sequence and rewrites it position by position through the mapping, falling back to the second input where no mapping entry is found."
2433
)
2534
)
2635
)
2736
@TransformExamples(Array(
2837
new TransformExample(
38+
description = "A single input value produces one combined SHA-256 hash.",
2939
input1 = Array("input value"),
3040
output = Array("f708c2afff0ed197e8551c4dd549ee5b848e0b407106cbdb8e451c8cd1479362")
3141
),
42+
new TransformExample(
43+
description = "Multiple values on one input are combined into a single hash.",
44+
input1 = Array("apple", "banana"),
45+
output = Array("5b692305517af54eb5ae12b9ff89eaf89e31f6a6ee208365886a18b81a2fc2f8")
46+
),
47+
new TransformExample(
48+
description = "Reversing the value order produces a different hash, confirming order-sensitivity.",
49+
input1 = Array("banana", "apple"),
50+
output = Array("d4183362b538440bb9a5f82359791c647280e6b657a1812f16f7bcc2b8f141ca")
51+
),
52+
new TransformExample(
53+
description = "Values from multiple ports are combined in port order, producing the same hash as the equivalent single-port sequence.",
54+
input1 = Array("apple"),
55+
input2 = Array("banana"),
56+
output = Array("5b692305517af54eb5ae12b9ff89eaf89e31f6a6ee208365886a18b81a2fc2f8")
57+
),
58+
new TransformExample(
59+
description = "The algorithm parameter selects the hash function (MD5).",
60+
parameters = Array("algorithm", "MD5"),
61+
input1 = Array("input value"),
62+
output = Array("cee963a28f70ee97751a85ef732e66dd")
63+
),
64+
new TransformExample(
65+
description = "The algorithm parameter selects the hash function (SHA-1).",
66+
parameters = Array("algorithm", "SHA-1"),
67+
input1 = Array("apple"),
68+
output = Array("d0be2dc421be4fcd0172e5afceea3970e2f3d940")
69+
),
70+
new TransformExample(
71+
description = "The algorithm parameter selects the hash function (SHA-384).",
72+
parameters = Array("algorithm", "SHA-384"),
73+
input1 = Array("apple"),
74+
output = Array("3d8786fcb588c93348756c6429717dc6c374a14f7029362281a3b21dc10250ddf0d0578052749822eb08bc0dc1e68b0f")
75+
),
76+
new TransformExample(
77+
description = "The algorithm parameter selects the hash function (SHA-512).",
78+
parameters = Array("algorithm", "SHA-512"),
79+
input1 = Array("apple"),
80+
output = Array("844d8779103b94c18f4aa4cc0c3b4474058580a991fba85d3ca698a0bc9e52c5940feb7a65a3a290e17e6b23ee943ecc4f73e7490327245b4fe5d5efb590feb2")
81+
),
82+
new TransformExample(
83+
description = "Empty input produces the hash of an empty message.",
84+
input1 = Array(),
85+
output = Array("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
86+
),
87+
new TransformExample(
88+
description = "Empty algorithm string causes IllegalArgumentException.",
89+
parameters = Array("algorithm", ""),
90+
input1 = Array("foo"),
91+
throwsException = classOf[IllegalArgumentException]
92+
),
3293
))
3394
case class InputHashTransformer(@Param(value = "The hash algorithm to be used.",
3495
autoCompletionProvider = classOf[HashAlgorithmAutoCompletionProvider], allowOnlyAutoCompletedValues = true)
35-
algorithm: String = "SHA256") extends InlineTransformer {
96+
algorithm: String = "SHA256") extends HashTransformer {
3697

3798
require(algorithm.trim.nonEmpty, "Algorithm must not be empty. Please specify an algorithm, such as 'SHA256'.")
3899

39100
override def apply(values: Seq[Seq[String]]): Seq[String] = {
40-
val hashSum = MessageDigest.getInstance(algorithm)
41-
for(value <- values; v <- value) {
42-
hashSum.update(v.getBytes(StandardCharsets.UTF_8))
43-
}
44-
// Convert the byte array to a hexadecimal string
45-
Seq(hashSum.digest().map("%02x".format(_)).mkString)
101+
def updateAll(digest: MessageDigest): Unit =
102+
values.flatten.foreach(updateWith(digest, _))
103+
104+
Seq(withDigest { digest =>
105+
updateAll(digest)
106+
toHex(digest.digest())
107+
})
46108
}
47109
}
48110

49111
object InputHashTransformer {
50112
final val pluginId = "inputHash"
51113
}
52-
53-
/**
54-
* Auto-completion for project resources.
55-
*/
56-
case class HashAlgorithmAutoCompletionProvider() extends PluginParameterAutoCompletionProvider {
57-
58-
private lazy val algorithms = {
59-
Security.getAlgorithms("MessageDigest").asScala.toSeq
60-
}
61-
62-
override def autoComplete(searchQuery: String, dependOnParameterValues: Seq[ParamValue],
63-
workspace: WorkspaceReadTrait)
64-
(implicit context: PluginContext): Iterable[AutoCompletionResult] = {
65-
66-
val multiSearchWords = extractSearchTerms(searchQuery)
67-
algorithms
68-
.filter(r => matchesSearchTerm(multiSearchWords, r.toLowerCase))
69-
.map(r => AutoCompletionResult(r, None))
70-
}
71-
72-
override def valueToLabel(value: String, dependOnParameterValues: Seq[ParamValue],
73-
workspace: WorkspaceReadTrait)
74-
(implicit context: PluginContext): Option[String] = {
75-
None
76-
}
77-
}

0 commit comments

Comments
 (0)