Skip to content
Open
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
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,6 @@ documented in the table below.
| `testQuantity4` | Test | | [PR](https://github.com/FHIR/fhir-test-cases/pull/243) | |
| `testSubSetOf3` | Specification/Test | | | The test resource is invalid and missing (https://github.com/FHIR/fhir-test-cases/issues/247); the scope of "$this" is unclear (https://jira.hl7.org/browse/FHIR-44601) |
| `testIif11` | Implementation | | | https://jira.hl7.org/browse/FHIR-44774; https://jira.hl7.org/browse/FHIR-44601 |
| `testEscape*` | Implementation | STU | | Function `escape` is not implemented. |
| `testUnescape*` | Implementation | STU | | Function `unescape` is not implemented. |
| `testNow1` | Specification/Test | | | As `testDateTimeGreaterThanDate1`. |
| `testSort8` | Specification/Test | | | Test uses `-$this` for descending string sort, but spec uses `asc`/`desc`, https://github.com/FHIR/fhir-test-cases/issues/253. |
| `testSort10` | Specification/Test | | | Test uses `-` prefix for descending sort, but spec uses `asc`/`desc`, https://github.com/FHIR/fhir-test-cases/issues/253. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ internal fun Collection<Any>.invoke(
// https://build.fhir.org/ig/HL7/FHIRPath/#additional-string-functions
"encode" -> this.encode(params, fhirPathTypeResolver)
"decode" -> this.decode(params, fhirPathTypeResolver)
"escape" -> this.escape(params, fhirPathTypeResolver)
"unescape" -> this.unescape(params, fhirPathTypeResolver)
"trim" -> this.trim(fhirPathTypeResolver)
"split" -> this.split(params, fhirPathTypeResolver)
"join" -> this.join(params, fhirPathTypeResolver)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,207 @@ private fun isUnreservedUrlChar(ch: Char): Boolean =
ch == '_' ||
ch == '.' ||
ch == '~'

/**
* Escapes the single string item in the input collection for the specified target. Supported
* targets: `'html'` and `'json'`.
*
* See [specification](https://build.fhir.org/ig/HL7/FHIRPath/#escapetarget--string--string).
*/
internal fun Collection<Any>.escape(
params: List<Any>,
fhirPathTypeResolver: FhirPathTypeResolver,
): Collection<String> {
check(size <= 1) { "escape() cannot be called on a collection with more than 1 item" }
val input = singleOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()
val target = params.singleOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()

return when (target) {
"html" -> listOf(htmlEscape(input))
"json" -> listOf(jsonEscape(input))
else -> emptyList()
}
}

/**
* Unescapes the single string item in the input collection for the specified target. Supported
* targets: `'html'` and `'json'`. Malformed input, such as a truncated `\uXXXX` escape or an entity
* with an invalid code point, returns empty rather than throwing.
*
* See [specification](https://build.fhir.org/ig/HL7/FHIRPath/#unescapetarget--string--string).
*/
internal fun Collection<Any>.unescape(
params: List<Any>,
fhirPathTypeResolver: FhirPathTypeResolver,
): Collection<String> {
check(size <= 1) { "unescape() cannot be called on a collection with more than 1 item" }
val input = singleOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()
val target = params.singleOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()

return try {
when (target) {
"html" -> listOf(htmlUnescape(input))
"json" -> listOf(jsonUnescape(input))
else -> emptyList()
}
} catch (_: Exception) {
emptyList()
}
}

/**
* Escapes the HTML special characters `&`, `<`, `>`, `"` and `'` as named or numeric entities, and
* every character above 127 as a numeric entity. A surrogate pair is escaped as a single entity for
* the full code point.
*
* These five are the only characters with markup meaning in HTML, matching the XML predefined
* entities (https://www.w3.org/TR/xml/#sec-predefined-ent) and OWASP's HTML encoding rule
* (https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).
*/
private fun htmlEscape(input: String): String = buildString {
var i = 0
while (i < input.length) {
val char = input[i]
val next = input.getOrNull(i + 1)
when {
char == '&' -> append("&amp;")
char == '<' -> append("&lt;")
char == '>' -> append("&gt;")
char == '"' -> append("&quot;")
// Numeric because HTML4 never defined `&apos;`.
char == '\'' -> append("&#39;")
char.isHighSurrogate() && next != null && next.isLowSurrogate() -> {
append("&#").append(surrogatePairToCodePoint(char, next)).append(';')
i += 2
continue
}
char.code > 127 -> append("&#").append(char.code).append(';')
else -> append(char)
}
i++
}
}

/**
* Decodes HTML character entities, both numeric (`&#65;`, `&#x41;`) and named. Only the five
* predefined names (`amp`, `lt`, `gt`, `quot`, `apos`) are decoded, which covers everything
* [htmlEscape] can produce. HTML defines many more names (`&nbsp;`, `&eacute;` and so on, see
* https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references); those are left
* in the string unchanged.
*/
private fun htmlUnescape(input: String): String = buildString {
var i = 0
while (i < input.length) {
val char = input[i]
if (char == '&') {
// The longest decodable entity is `&#1114111;`, the last Unicode code point
// (https://www.unicode.org/glossary/#code_point), whose `;` is 9 characters from the `&`.
val end = input.indexOf(';', i)
val decoded =
if (end in (i + 1)..(i + 9)) decodeHtmlEntity(input.substring(i + 1, end)) else null
if (decoded != null) {
append(decoded)
i = end + 1
continue
}
}
append(char)
i++
}
}

/** Decodes the text between `&` and `;`, or returns null if it is not a recognized entity. */
private fun decodeHtmlEntity(entity: String): String? =
when {
entity == "amp" -> "&"
entity == "lt" -> "<"
entity == "gt" -> ">"
entity == "quot" -> "\""
entity == "apos" -> "'"
entity.startsWith("#x") || entity.startsWith("#X") ->
codePointToString(entity.drop(2).toInt(16))
entity.startsWith("#") -> codePointToString(entity.drop(1).toInt())
else -> null
}

/**
* Escapes `\`, `"` and control characters as in a JSON string literal (RFC 8259,
* https://datatracker.ietf.org/doc/html/rfc8259#section-7).
*/
private fun jsonEscape(input: String): String = buildString {
Comment thread
FikriMilano marked this conversation as resolved.
for (char in input) {
when (char) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
// The remaining control characters have no dedicated escape.
in '\u0000'..'\u001F' -> append("\\u").append(char.code.toString(16).padStart(4, '0'))
else -> append(char)
}
}
}

/** Decodes JSON string literal escape sequences, including `\uXXXX`. */
private fun jsonUnescape(input: String): String = buildString {
var i = 0
while (i < input.length) {
val char = input[i]
val next = input.getOrNull(i + 1)
if (char == '\\' && next != null) {
when (next) {
'"' -> append('"')
'\\' -> append('\\')
'/' -> append('/')
'n' -> append('\n')
'r' -> append('\r')
't' -> append('\t')
'b' -> append('\b')
'f' -> append('\u000C')
'u' -> {
// `\uXXXX` is a UTF-16 code unit, so a code point above 0xFFFF arrives as two escapes,
// a surrogate pair, and appending each unit as a Char combines them.
append(Char(input.substring(i + 2, i + 6).toInt(16)))
i += 6
continue
}
// An escape JSON does not define, like FHIRPath's `\'`, keeps the escaped character.
else -> append(next)
}
i += 2
} else {
append(char)
i++
}
}
}

/**
* Reverses the encoding in [codePointToString]: strip the 0xD800 and 0xDC00 markers, rejoin the two
* 10 bit halves, add back the 0x10000.
*/
private fun surrogatePairToCodePoint(high: Char, low: Char): Int =
0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00)

/**
* Converts a Unicode code point to a string: a single `Char` for code points up to `0xFFFF`, and a
* surrogate pair for those above, which do not fit in one 16 bit `Char`.
*/
private fun codePointToString(codePoint: Int): String {
// No character exists above 0x10FFFF, and 0xD800..0xDFFF is set aside for the two char
// encoding below, so neither is a real character.
require(codePoint in 0..0x10FFFF && codePoint !in 0xD800..0xDFFF) {
"Invalid code point: $codePoint"
}
return if (codePoint <= 0xFFFF) {
Char(codePoint).toString()
} else {
// Too big for one 16 bit char, so it is split in two. Subtracting 0x10000 makes it fit in
// 20 bits, and the 0xD800 and 0xDC00 markers show which char is which half.
val offset = codePoint - 0x10000
charArrayOf(Char(0xD800 + (offset shr 10)), Char(0xDC00 + (offset and 0x3FF))).concatToString()
Comment thread
FikriMilano marked this conversation as resolved.
}
}
Loading
Loading