Merge pull request #8397 from ErikMelton/unauthorized-http-get-requests-from-notifications

Require textFormat declaration for all Text elements
This commit is contained in:
Ryan Hughes
2026-08-28 15:50:19 -04:00
committed by GitHub
43 changed files with 1068 additions and 2 deletions
+145
View File
@@ -18,6 +18,151 @@ assertEqual(
'notifications strip inline image tags'
)
// The body renders as StyledText, which fetches <img src> over the network. The
// invariant that matters is not a particular output string but that no tag Qt
// would honour as an image survives, so assert that directly. Tags are bounded
// the conservative way the stripper bounds them: a `<` opens a tag that runs to
// the next `>`. Qt's own bound can be longer, since a `>` inside a quoted
// attribute value does not close a tag there — which only ever splits one Qt
// tag into several here, so a name this helper reads is a name Qt reads too.
function survivingTagNames(text) {
const names = []
let i = 0
while (i < text.length) {
const open = text.indexOf('<', i)
if (open === -1) break
const close = text.indexOf('>', open)
const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1)
// Read the name the way Qt does, skipping anything that is not part of it.
// Matching the separator with \s instead would give this helper the same
// blind spot as the code it is checking — Qt skips U+0085 and \s does not —
// and an assertion that shares the implementation's bug proves nothing.
const name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag)
if (name) names.push(name[1].toLowerCase())
i = close === -1 ? text.length : close + 1
}
return names
}
// Assert on styledBody, not sanitizeBody: styledBody is the string the card
// binds to the StyledText, so it is the only one Qt ever parses. Checking the
// sanitizer's output instead would pass a body whose surviving tag the newline
// rewrite later splits open.
function assertNoImageSurvives(body, description) {
const out = notifications.styledBody(body, 'Slack', '')
const names = survivingTagNames(out)
assert(
!names.includes('img'),
description,
`input: ${body}\noutput: ${out}\ntags: ${JSON.stringify(names)}`
)
}
assertNoImageSurvives(
'<img src="http://host/plain.png">',
'notifications leave no image tag for a plain payload'
)
// A payload spliced inside the literal "<img" prefix. Qt reads ONE malformed
// tag named `im` here and renders nothing; a stripper that deleted the inner
// match would close the halves up into a live <img> the input never had.
assertNoImageSurvives(
'<im<img src="http://host/decoy.png">g src="http://host/beacon.png">',
'notifications leave no image tag when a payload is spliced inside <img'
)
assertNoImageSurvives(
'<im<im<img src=a>g src=b>g src="http://host/deep.png">',
'notifications leave no image tag for a doubly nested payload'
)
assertNoImageSurvives(
'<img<img src="http://host/twin.png">',
'notifications leave no image tag when the outer tag is itself named img'
)
assertNoImageSurvives(
'< img src="http://host/spaced.png">',
'notifications leave no image tag when whitespace follows the angle bracket'
)
// Qt skips the separator between `<` and the tag name with QChar::isSpace(),
// which counts U+0085 NEL. JavaScript's \s does not. Reading the name with \s
// finds none here, keeps the tag, and Qt then reads `img` and fetches it —
// measured against Qt 6.11.2, where this exact body makes a StyledText Text
// issue an outbound GET. Asserted on the whole output rather than through
// assertNoImageSurvives so it holds even if that helper is ever loosened.
assertEqual(
notifications.sanitizeBody('<\u0085img src="http://host/nel.png">after', 'Slack', ''),
'after',
'notifications strip an image tag whose separator is U+0085, which Qt skips but \\s does not'
)
assertNoImageSurvives(
'<\u0085img src="http://host/nel2.png">',
'notifications leave no image tag when U+0085 follows the angle bracket'
)
// The card rewrites newlines to <br/> for the StyledText, which puts tag syntax
// inside a tag the stripper kept: `<x`, newline, `<img …>` is one tag named `x`
// to both the stripper and Qt, and the rewrite splits it into `<x<br/>` and a
// live image tag. Measured against Qt 6.11.2 — the rewritten form issues the GET
// and the original does not — so the strip has to run after the rewrite, which
// is what styledBody() does.
assertNoImageSurvives(
'<x\n<img src="http://host/split.png">',
'notifications leave no image tag when a newline rewrite splits a kept tag'
)
assertNoImageSurvives(
'<x\r\n<img src="http://host/split-crlf.png">',
'notifications leave no image tag when a CRLF rewrite splits a kept tag'
)
assertEqual(
notifications.styledBody('<x\n<img src="http://host/split.png">', 'Slack', ''),
'<x<br/>',
'notifications drop the image half of a tag the newline rewrite splits'
)
// The rewrite itself still happens, and body markup other than images survives it.
assertEqual(
notifications.styledBody('<b>bold</b>\nsecond line', 'Slack', ''),
'<b>bold</b><br/>second line',
'notifications keep body markup and the line break the card renders'
)
// The order above is only worth anything if the card actually renders it, and no
// JavaScript assertion can see a QML binding. Pin the binding itself: the rewrite
// belongs in the logic module, where the strip runs after it.
const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8')
assert(
/readonly property string styledBody: NotificationLogic\.styledBody\(body, app, appIcon\)/.test(cardQml),
'the notification card renders the body that was stripped after the newline rewrite'
)
assert(
!/<br\/>/.test(cardQml),
'the notification card does not rewrite newlines itself, which would leave tag syntax unchecked'
)
assertEqual(
notifications.sanitizeBody('trailing <img src="http://host/z.png"', 'Slack', ''),
'trailing ',
'notifications strip an unterminated image tag the renderer would close itself'
)
assertEqual(
notifications.sanitizeBody('<IMG SRC="http://host/u.png">shout', 'Slack', ''),
'shout',
'notifications strip image tags regardless of case'
)
assertEqual(
notifications.sanitizeBody('<b>bold</b> and <a href="http://host">link</a>', 'Slack', ''),
'<b>bold</b> and <a href="http://host">link</a>',
'notifications keep the body markup the body-markup capability advertises'
)
assertEqual(
notifications.sanitizeBody('<a href="https://example.com">example.com</a> Message body', 'Chromium', ''),
'Message body',
+373
View File
@@ -0,0 +1,373 @@
"""Report every QML Text that renders a non-literal value without a textFormat.
Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per
violation, exits 1 on an unreadable tree). Lives in its own file rather than a
heredoc so the test can run it over fixtures and prove it still fails when it
should — a guard nothing can fail is a guard nobody should trust.
Two limits are deliberate, because a line scanner cannot close them. It reads
each Text element's own declaration, so text assigned from somewhere else —
`Binding { target: label; property: "text" }`, `PropertyChanges`, a
`Component.onCompleted` assignment, a `property alias` onto a child's text —
is invisible to it. And a regex literal containing a brace throws off the brace
depth. Neither shape exists in this tree; both would need a QML parser.
"""
import os
import re
import sys
from pathlib import Path
BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S)
def strip_block_comments(text):
"""Blank out /* */ comments, keeping every newline so line numbers hold.
strip_noise() only knows `//`, so before this a block comment between a
type name and its brace — `Text /* why */ {` — hid the element from
OPEN_ELEMENT and from the unscannable-form check alike, and the block
passed with no textFormat at all.
"""
out = []
i = 0
quote = None
while i < len(text):
c = text[i]
if quote:
if c == '\\':
out.append(text[i:i + 2])
i += 2
continue
if c == quote:
quote = None
out.append(c)
i += 1
continue
if c in '"\'':
quote = c
out.append(c)
i += 1
continue
if c == '/' and text.startswith('//', i):
end = text.find('\n', i)
if end == -1:
break
out.append(text[i:end])
i = end
continue
if c == '/' and text.startswith('/*', i):
end = text.find('*/', i + 2)
end = len(text) if end == -1 else end + 2
out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end]))
i = end
continue
out.append(c)
i += 1
return ''.join(out)
# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is
# the same element and was skipped, because the name compared unequal to `Text`.
TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text'
OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$')
INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$')
INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{')
PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:')
STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b')
# A binding that runs onto the next line: this line ends on an operator, or the
# next line opens with one.
TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$')
LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])')
def strip_noise(line, keep_strings=False):
out = []
i = 0
quote = None
while i < len(line):
c = line[i]
if quote:
if keep_strings:
out.append(c)
if c == '\\':
if keep_strings and i + 1 < len(line):
out.append(line[i + 1])
i += 2
continue
if c == quote:
quote = None
if not keep_strings:
out.append('S')
i += 1
continue
if c in '"\'':
quote = c
if keep_strings:
out.append(c)
i += 1
continue
if c == '/' and i + 1 < len(line) and line[i + 1] == '/':
break
out.append(c)
i += 1
return ''.join(out)
def is_pure_literal(expr):
residue = STRING_LITERAL.sub('', expr)
residue = re.sub(r'[\s+]', '', residue)
return residue == '' and STRING_LITERAL.search(expr) is not None
def binding_expression(lines, start):
"""The whole right-hand side of the binding beginning on line `start`.
The literal exemption has to be judged on the complete expression. Reading
only the physical `text:` line would exempt `text: "prefix"` while
`+ externalValue` sits underneath, letting a dynamic AutoText binding
through. Reading a wrapped concatenation of literals as dynamic would be
the opposite error, so follow the expression to its end either way.
"""
parts = []
parens = brackets = 0
i = start
while i < len(lines):
parts.append(strip_noise(lines[i], keep_strings=True))
counted = strip_noise(lines[i])
parens += counted.count('(') - counted.count(')')
brackets += counted.count('[') - counted.count(']')
# Look past blank and comment-only lines for the continuation. A
# comment or a blank line dropped into a wrapped expression does not
# end it, and stopping there would read `text: "prefix"` as the whole
# binding and exempt it as a literal while `+ externalValue` waits
# below — the exact misreading this function exists to prevent.
following = ''
for ahead in range(i + 1, len(lines)):
candidate = strip_noise(lines[ahead])
if candidate.strip():
following = candidate
break
continues = (parens > 0 or brackets > 0
or TRAILING_OPERATOR.search(counted.rstrip())
or LEADING_OPERATOR.match(following))
if not continues:
break
i += 1
chunk = ' '.join(parts)
return chunk.split(':', 1)[1] if ':' in chunk else chunk
def exempt_as_literal(lines, tline):
"""True when the binding is only string literals, however many lines."""
return is_pure_literal(binding_expression(lines, tline))
def blocks(lines):
stack = []
done = []
depth = 0
for idx, raw in enumerate(lines):
code = strip_noise(raw)
opened = OPEN_ELEMENT.search(code)
prop = PROP.match(code)
if (prop and stack and stack[-1]['depth'] == depth
and not opened and not PROPERTY_DECL.match(code)):
stack[-1]['props'].setdefault(prop.group(1), idx)
n_open = code.count('{')
n_close = code.count('}')
depth += n_open - n_close
if opened and n_open > 0:
# OPEN_ELEMENT anchors at the end of the line, so the element it
# matched is the innermost one opened here and its depth is the
# depth after every brace on the line.
stack.append({'name': opened.group(1), 'depth': depth,
'props': {}, 'start': idx})
while stack and depth < stack[-1]['depth']:
done.append(stack.pop())
done.extend(stack)
return done
INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}')
INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)')
# As a property of this block, not as a substring: `visible: root.textFormatEnabled`
# used to read as a declaration and exempt the element.
INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:')
def inline_violations(lines, rel):
"""Whole Text blocks written on one line.
OPEN_ELEMENT anchors at the end of the line, so the brace scanner never
sees these. A Repeater delegate is a plausible place for one.
"""
out = []
for idx, raw in enumerate(lines):
code = strip_noise(raw, keep_strings=True)
for match in INLINE_TEXT.finditer(code):
body = match.group(1)
if INLINE_TEXT_FORMAT.search(body):
continue
# A component root written on one line needs the default whether or
# not this line binds `text`, for the same reason the block form
# does: every caller supplies the binding.
if INLINE_COMPONENT_ONELINE.match(code):
out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat')
continue
binding = INLINE_BINDING.search(body)
if not binding or is_pure_literal(binding.group(1)):
continue
out.append(f'{rel}:{idx + 1}: inline Text block without textFormat')
return out
# `Text { text: someValue` with the block carrying on below is valid QML and is
# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the
# line so the brace tracker never opens the block, and INLINE_TEXT needs the
# closing brace on the same line. A dynamic AutoText binding written that way
# passes this file in silence, which is the one failure a test like this must
# not have.
#
# Rather than teach a line scanner to parse QML, require the two forms it can
# read: the whole block on one line, or nothing after the opening brace. Every
# Text in this tree is already written that way, so keeping to it costs nothing.
UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S')
BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$')
UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the '
'opening brace last on the line, or write the whole block on '
'one line with no nested braces')
COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$')
def opens_component(lines, start):
"""True when the Text block at `start` is a component root declared above it."""
for back in range(start - 1, -1, -1):
code = strip_noise(lines[back]).strip()
if not code:
continue
return bool(COMPONENT_OPENER.match(lines[back]))
return False
def unscannable_violations(lines, rel):
out = []
for idx, raw in enumerate(lines):
code = strip_noise(raw)
# `Text` with its brace on the next line. OPEN_ELEMENT needs both on
# one line, so the block is never opened and everything in it is
# attributed to the enclosing element instead.
if BARE_TEXT_OPENER.search(code):
following = ''
for ahead in range(idx + 1, len(lines)):
candidate = strip_noise(lines[ahead]).strip()
if candidate:
following = candidate
break
if following.startswith('{'):
out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}')
continue
for match in UNSCANNABLE_TEXT.finditer(code):
# A complete one-line block with no nested braces is fine —
# inline_violations reads those. Count rather than looking for a
# `}`, because `Text { text: ({ a: external }).a }` closes on this
# line yet INLINE_TEXT's brace-free body pattern cannot match it,
# so treating any `}` as "handled elsewhere" would drop it.
rest = code[match.end() - 1:]
depth = 1
closed = False
for char in rest:
if char == '{':
depth += 1
elif char == '}':
depth -= 1
if depth == 0:
closed = True
break
if closed and '{' not in rest:
continue
out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}')
return out
root = Path(sys.argv[1])
found = []
scanned = 0
def unreadable(error):
# rglob() swallows a directory it cannot enter, so a shell/ subtree with no
# read permission scanned as though it were empty and the run reported
# success. Same failure as an empty tree, and it fails the same way.
raise SystemExit(f'cannot read {error.filename}: {error.strerror}')
qml = []
for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable):
dirnames.sort()
qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml'))
for path in sorted(qml):
scanned += 1
lines = strip_block_comments(path.read_text()).splitlines()
rel = path.relative_to(root)
found.extend(inline_violations(lines, rel))
found.extend(unscannable_violations(lines, rel))
for b in blocks(lines):
if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']:
continue
# Read the block's own properties. A nested child declaring textFormat
# says nothing about its parent, so `Text { Text { textFormat: ... } }`
# must still report the outer element.
# The root element of a component takes its binding from callers, so it
# needs the default whether or not this file binds `text`. Require both
# depth 1 and column 0: the scanner attributes one element per line, so
# a `Row { Text {` line would report depth 1 for a nested block, and
# falling through to the binding check below is the safe reading.
# Indentation is not what makes it a root; depth 1 is. A `Row { Text {`
# line still reads as `Row` here, so leading whitespace can be ignored
# without letting a nested block be mistaken for the file's root.
if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'):
found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat')
continue
# A QML inline component is a root for the same reason, and the rule
# above cannot see one: `component InfoValue: Text {` sits inside
# another element, so its depth is not 1 and its line does not start
# with `Text`. Its `text` comes from every caller, so the file it lives
# in never binds it and the binding check below lets it through in
# silence. Only one file-level root Text exists in this tree, so
# without this the root rule is very nearly dead code.
# `component Info:` may also put its `Text {` on the following line,
# which INLINE_COMPONENT cannot match and which then reads as an
# ordinary nested block with no binding of its own — a caller's dynamic
# text passing in silence.
if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']):
found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat')
continue
if 'text' not in b['props']:
continue
tline = b['props']['text']
if exempt_as_literal(lines, tline):
continue
found.append(f'{rel}:{tline + 1}: text binding without textFormat')
# A scan that read nothing reports nothing, and an all-clear from a run that
# never opened a file is the one result this test must never give. Only a
# checkout with no shell/ QML at all reaches this.
if scanned == 0:
raise SystemExit('no .qml files found under shell/; the scan read nothing')
for line in found:
print(line)
+276
View File
@@ -0,0 +1,276 @@
#!/bin/bash
# A QML Text element with no textFormat uses Text.AutoText. Qt then runs
# mightBeRichText() over the string and promotes it to Text.RichText when it
# looks like markup, and RichText fetches <img src="http://..."> through
# QQuickPixmap. Any string that reaches such an element from outside the shell
# — a notification summary, an MPRIS track title, a window title, an SSID, a
# Bluetooth device name, clipboard content, a weather API response — can
# therefore make the shell issue an unauthenticated outbound GET with no user
# interaction.
#
# The promotion needs only that the attacker contribute the first `<` in the
# string, on the first line. A fixed label in front of the value does not
# protect it, and neither does .toUpperCase(), because the parser lowercases
# the tag before looking it up.
#
# So require an explicit textFormat on every Text whose text: binding is not a
# bare string literal. A literal carries no external data, so AutoText has
# nothing to promote; this test is what catches the edit that later turns such
# a literal into an expression.
#
# The scan itself lives in qml-text-format-scan.py. It is run twice: over the
# real tree, and over the fixtures below, which are the forms that have already
# slipped past it once. A guard nothing can fail is a guard nobody should trust,
# and every one of those fixtures passed silently before it was written down.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command python3
SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py"
violations=$(python3 "$SCAN" "$ROOT")
if [[ -n $violations ]]; then
count=$(printf '%s\n' "$violations" | wc -l)
fail "every Text with a dynamic text binding declares textFormat" \
"$violations
$count Text element(s) rely on Text.AutoText for a non-literal binding.
Add an explicit textFormat. Text.PlainText is right for anything that renders
data from outside the shell; use Text.StyledText only where markup is a
deliberate, documented feature, and strip <img> before it reaches the renderer."
fi
pass "every Text with a dynamic text binding declares textFormat"
# The scanner's own tests. Each fixture is a Text that renders external data
# with no textFormat, written in a form that once passed. `caught` asserts the
# scan reports something; `clean` asserts it does not, so the fixtures prove the
# scanner can fail rather than that it fails at everything.
fixture_root=$(mktemp -d)
trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT
function scan_fixture {
local name=$1
local dir="$fixture_root/$name"
mkdir -p "$dir/shell/Ui"
cat > "$dir/shell/Ui/Fixture.qml"
python3 "$SCAN" "$dir" 2>&1
}
function caught {
local name=$1 description=$2 output
output=$(scan_fixture "$name" || true)
if [[ -z $output ]]; then
fail "$description" "the scan reported nothing for fixture $name"
fi
pass "$description"
}
function clean {
local name=$1 description=$2 output
output=$(scan_fixture "$name" || true)
if [[ -n $output ]]; then
fail "$description" "the scan reported: $output"
fi
pass "$description"
}
caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
text: external
}
}
QML
clean literal "the scan leaves a string literal alone" <<'QML'
import QtQuick
Item {
Text {
text: "a literal"
}
}
QML
clean declared "the scan leaves a declared textFormat alone" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
textFormat: Text.PlainText
text: external
}
}
QML
# strip_noise() knew `//` and not `/* */`, so a block comment between the type
# name and its brace hid the whole element from every rule.
caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML'
import QtQuick
Item {
property string external: "x"
Text /* explanation */ {
text: external
}
}
QML
caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML'
import QtQuick
Item {
property string external: "x"
/*
* Text { text: "not this one" }
*/
Text {
text: external
}
}
QML
# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to
# `Text` and was skipped outright.
caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text {
text: external
}
}
QML
# textFormat was matched as a substring, so any property whose name merely
# started that way exempted the element.
caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text { text: external }
}
QML
caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text { text: external
color: "red"
}
}
QML
caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML'
import QtQuick
Item {
property string external: "x"
property bool textFormatEnabled: true
Text { text: external; visible: textFormatEnabled }
}
QML
# A component root takes its text from every caller, so the file it lives in
# never binds it. The one-line form was covered; this one was not.
caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML'
import QtQuick
Item {
component Info:
Text {
}
}
QML
caught component-one-line "the scan reads a component root written on one line" <<'QML'
import QtQuick
Item {
component Info: Text { color: "red" }
}
QML
# Forms the scanner cannot read are reported rather than passed, which is the
# whole reason it can be a line scanner at all.
caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML'
import QtQuick
Item {
property string external: "x"
Text
{
text: external
}
}
QML
caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML'
import QtQuick
Item {
property string external: "x"
Text { text: external
color: "red"
}
}
QML
# A wrapped binding is judged whole: a literal first line says nothing about
# what is concatenated onto it below.
caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
text: "prefix"
+ external
}
}
QML
clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML'
import QtQuick
Item {
Text {
text: "one"
+ "two"
}
}
QML
# A nested child's textFormat says nothing about its parent.
caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML'
import QtQuick
Text {
text: external.value
Text {
textFormat: Text.PlainText
text: "literal"
}
}
QML
# A scan that reads less than the tree holds must not report success. Both of
# these once did.
empty_root=$(mktemp -d)
mkdir -p "$empty_root/shell"
if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then
rm -rf "$empty_root"
fail "the scan fails when it reads no files" "an empty shell/ tree exited 0"
fi
rm -rf "$empty_root"
pass "the scan fails when it reads no files"
blind_root="$fixture_root/blind"
mkdir -p "$blind_root/shell/Ui/locked"
printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml"
printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml"
chmod 000 "$blind_root/shell/Ui/locked"
if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then
chmod 755 "$blind_root/shell/Ui/locked"
fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0"
fi
chmod 755 "$blind_root/shell/Ui/locked"
pass "the scan fails when a directory hides files from it"