diff --git a/COVERAGE.md b/COVERAGE.md
index cac243a..ea10671 100644
--- a/COVERAGE.md
+++ b/COVERAGE.md
@@ -67,6 +67,56 @@ project-authored code, with the inherited modules without tests omitted in
`pyproject.toml` until their tests land. Both thresholds are only ever
raised. The sections that follow record the state before the merges.
+## Branch fix/no-third-party-script: the first boot page (2026-09-28)
+
+`firstboot.d/29tagid` and `lib/tagid.sh` stop putting a third party script
+on the page the appliance serves before anybody has logged in
+(Keel-Linux/inithooks#13). `tests/test-tagid.bats` grows from 14 to 27
+tests. Measured with kcov 43 and bats 1.11, 124 bats over the six files:
+
+| File | Before | After |
+| --- | --- | --- |
+| `firstboot.d/29tagid` | 19/19 | 17/17, 100 percent |
+| `lib/tagid.sh` | 8/8 | 2/2, 100 percent |
+
+Both files shrank because four functions went away with the scripts they
+built. Total 99.53 (was 99.40). The lowest file is still
+`lib/init-fence.sh` at 98.44, so the shell gate stays at 98. Python is
+untouched: 187 passed, 3 skipped.
+
+**What is asserted, and what is not.** The verdict is taken from the
+rendered page, not from the source of the hook: the page is parsed for
+every host a `
-
-EOT
-}
diff --git a/tests/remote_loads.py b/tests/remote_loads.py
new file mode 100755
index 0000000..69a2abb
--- /dev/null
+++ b/tests/remote_loads.py
@@ -0,0 +1,133 @@
+#!/usr/bin/python3
+# Copyright (c) 2026 TurnKey GNU/Linux
+"""Print every URL on another host that an HTML page fetches on its own
+
+ remote_loads.py PAGE
+
+Exit status: 0 the page fetches nothing from another host, 1 it does (each
+URL printed on a line of its own), 2 the page could not be read.
+
+"On its own" is what a browser does while it renders the page, before
+anybody clicks: scripts, stylesheets and every other link element, images
+and srcsets, frames, objects, media and their posters and tracks, a base
+URL, a meta refresh, and url() and @import in style attributes and style
+elements. A link (, ) and a form action are somewhere the operator
+may choose to go, and are not reported. Inline script is not parsed.
+
+"Another host" is any URL with a scheme other than data, or with no scheme
+and two leading slashes. A browser strips tabs and newlines from a URL and
+reads a backslash as a slash, and so does this, so neither is a way past
+it. When in doubt, a URL is reported.
+
+It is a test instrument, not a defence: tests/test-tagid.bats asks it about
+every page the fence serves. It is built on Python's HTMLParser, which does
+not parse malformed markup the way a browser does (an abruptly closed
+comment such as '' hides what follows from it, and not from a
+browser), so it says what a well formed page loads, and the hook does not
+rely on it: firstboot.d/29tagid serves the packaged page and nothing else.
+"""
+
+import re
+import sys
+from html.parser import HTMLParser
+
+# attributes that make the element fetch, whatever the element is
+FETCHING = {"src", "srcset", "poster", "data", "background", "lowsrc"}
+# href fetches on every element but these, which navigate on a click
+NAVIGATING = {"a", "area"}
+# a scheme that does not reach a host
+LOCAL_SCHEMES = {"data", "about", "blob", "javascript"}
+
+CSS_URL = re.compile(r"""url\(\s*(['"]?)(.*?)\1\s*\)""", re.I | re.S)
+CSS_IMPORT = re.compile(r"""@import\s+(['"])(.*?)\1""", re.I | re.S)
+REFRESH_URL = re.compile(r"""^\s*\d*\s*[;,]?\s*url\s*=\s*['"]?([^'"]*)""",
+ re.I)
+SCHEME = re.compile(r"^([a-z][a-z0-9+.-]*):", re.I)
+
+
+def is_remote(url: str) -> bool:
+ """Whether a browser would reach another host for URL"""
+ url = re.sub(r"[\t\n\r]", "", url).strip().replace("\\", "/")
+ if url.startswith("//"):
+ return True
+ match = SCHEME.match(url)
+ return bool(match) and match.group(1).lower() not in LOCAL_SCHEMES
+
+
+def srcset_urls(value: str) -> list:
+ """The URLs of a srcset: each candidate is a URL and a descriptor"""
+ return [part.split()[0] for part in value.split(",") if part.split()]
+
+
+def css_urls(text: str) -> list:
+ """The URLs a stylesheet or a style attribute fetches"""
+ return ([m.group(2) for m in CSS_URL.finditer(text)]
+ + [m.group(2) for m in CSS_IMPORT.finditer(text)])
+
+
+class _Finder(HTMLParser):
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.found = []
+ self._in_style = False
+
+ def _check(self, urls):
+ self.found.extend(url for url in urls if is_remote(url))
+
+ def handle_starttag(self, tag, attrs):
+ tag = tag.lower()
+ values = {}
+ for name, value in attrs:
+ values[name.lower()] = value or ""
+ for name, value in values.items():
+ if name in FETCHING:
+ self._check(srcset_urls(value) if name == "srcset"
+ else [value])
+ elif name in ("href", "xlink:href") and tag not in NAVIGATING:
+ self._check([value])
+ elif name == "style":
+ self._check(css_urls(value))
+ if tag == "meta" and values.get("http-equiv", "").lower() == "refresh":
+ match = REFRESH_URL.match(values.get("content", ""))
+ if match:
+ self._check([match.group(1)])
+ if tag == "style":
+ self._in_style = True
+
+ handle_startendtag = handle_starttag
+
+ def handle_endtag(self, tag):
+ if tag.lower() == "style":
+ self._in_style = False
+
+ def handle_data(self, data):
+ if self._in_style:
+ self._check(css_urls(data))
+
+
+def remote_loads(page: str) -> list:
+ """Every URL on another host PAGE fetches on its own, in page order"""
+ finder = _Finder()
+ finder.feed(page)
+ finder.close()
+ return [url.strip() for url in finder.found]
+
+
+def main(argv: list) -> int:
+ if len(argv) != 1:
+ print("usage: remote_loads.py PAGE", file=sys.stderr)
+ return 2
+ try:
+ with open(argv[0], "rb") as fob:
+ page = fob.read().decode("utf-8", errors="replace")
+ except OSError as exc:
+ print(f"remote_loads.py: {exc}", file=sys.stderr)
+ return 2
+ found = remote_loads(page)
+ for url in found:
+ print(url)
+ return 1 if found else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/tests/test-tagid.bats b/tests/test-tagid.bats
index ef71524..7bb886c 100644
--- a/tests/test-tagid.bats
+++ b/tests/test-tagid.bats
@@ -1,10 +1,18 @@
#!/usr/bin/env bats
-# Tests for lib/tagid.sh and firstboot.d/29tagid.
+# Tests for lib/tagid.sh and firstboot.d/29tagid: the pages the appliance
+# serves on first boot, before anybody has logged in.
#
-# The library functions are called directly; the hook is run against scratch
-# copies of /etc/turnkey_version, /etc/apt/apt.conf.d/01turnkey,
-# /etc/default/turnkey-init-fence and the fence htdocs, with systemctl
-# replaced by a stub.
+# The verdict that matters is taken from what is served, not from the
+# source of the hook (docs/traps.md, "Asserting the configuration is not
+# asserting the behaviour"), and by two detectors that do not share a
+# parser: tests/remote_loads.py, which parses the page with Python's
+# HTMLParser and knows every attribute and style construct that fetches,
+# and remote_tokens below, which knows no HTML at all and reports any URL
+# with a host in any tag that is not a link, in any quoting or none. A grep
+# for one known hostname would pass the day somebody adds a different one.
+#
+# Refutations are written "run ! cmd", never a bare "! cmd": bash does not
+# apply errexit to a negated command, so a bare one asserts nothing.
bats_require_minimum_version 1.5.0
@@ -14,7 +22,8 @@ REPO=$BATS_TEST_DIRNAME/..
HOOK=$REPO/firstboot.d/29tagid
TKL_VERSION=turnkey-core-19.0-trixie-amd64
APT_LINE='Acquire::http::User-Agent "TurnKey APT-HTTP/1.3 (turnkey-core-19.0-trixie-amd64)";'
-SCRIPTS='
+# what an image built before Keel-Linux/inithooks#13 put at the end of the page
+OLD_SCRIPTS='
'
setup() {
@@ -26,10 +35,9 @@ exit 0'
# a scratch /usr/lib/inithooks with the packaged htdocs and the library
export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks
- mkdir -p "$INITHOOKS_PATH/turnkey-init-fence/htdocs"
+ mkdir -p "$INITHOOKS_PATH/turnkey-init-fence"
ln -s "$REPO/lib" "$INITHOOKS_PATH/lib"
- cp "$REPO/turnkey-init-fence/htdocs/index.html" \
- "$INITHOOKS_PATH/turnkey-init-fence/htdocs/index.html"
+ cp -R "$REPO/turnkey-init-fence/htdocs" "$INITHOOKS_PATH/turnkey-init-fence/"
export HTDOCS=$BATS_TEST_TMPDIR/var/turnkey-init-fence/htdocs
export INITFENCE_DEFAULT=$BATS_TEST_TMPDIR/default-turnkey-init-fence
@@ -39,87 +47,281 @@ exit 0'
export APT_CONF_TURNKEY=$BATS_TEST_TMPDIR/01turnkey
echo "$TKL_VERSION" > "$TURNKEY_VERSION_FILE"
echo "$APT_LINE" > "$APT_CONF_TURNKEY"
-}
-# ---------------------------------------------------------------- library
+ PACKAGED=$REPO/turnkey-init-fence/htdocs/index.html
+ RENDERED=$HTDOCS/index.html
+ # the page the hook is expected to serve: the packaged one, named
+ EXPECTED=$BATS_TEST_TMPDIR/expected.html
+ sed 's|@APP_NAME@|core|' "$PACKAGED" > "$EXPECTED"
+}
-@test "app_name strips the turnkey prefix and the version" {
- [ "$(tagid_app_name turnkey-core-19.0-trixie-amd64)" = core ]
- [ "$(tagid_app_name turnkey-wordpress-19.0-trixie-amd64)" = wordpress ]
- [ "$(tagid_app_name turnkey-gitea-19.1-trixie-arm64)" = gitea ]
+# remote_loads FILE
+# What tests/remote_loads.py says FILE fetches from another host.
+remote_loads() {
+ python3 "$BATS_TEST_DIRNAME/remote_loads.py" "$1"
}
-@test "version keeps the last three dash separated fields" {
- [ "$(tagid_version turnkey-core-19.0-trixie-amd64)" = 19.0-trixie-amd64 ]
- [ "$(tagid_version turnkey-gitea-19.1-trixie-arm64)" = 19.1-trixie-arm64 ]
+# remote_tokens FILE
+# Every URL with a host in a tag of FILE that is not a link (, ),
+# and in its style elements, in any quoting or none: scheme://host,
+# //host, and the backslash forms a browser reads the same way. Knows no
+# HTML beyond where a tag starts and ends, so it is wider than any parser
+# and never quieter than one about a well formed page.
+remote_tokens() {
+ tr '\n' ' ' < "$1" \
+ | grep -oiE '<[a-z!/][^>]*>|' \
+ | grep -viE '^<(a|area)[[:space:]/>]' \
+ | grep -oiE '([a-z][a-z0-9+.-]*:)?[/\\]{2}[^/\\[:space:]"'"'"')>]+' \
+ || true
}
-@test "build is iso when the user agent has no tags" {
- [ "$(tagid_build "$APT_LINE")" = iso ]
+# serves_nothing_remote DIR
+# Every file the fence would serve from DIR loads nothing from another host,
+# by both detectors.
+serves_nothing_remote() {
+ local file
+ for file in "$1"/*; do
+ if [[ -n "$(remote_tokens "$file")" ]]; then
+ echo "remote_tokens: $file: $(remote_tokens "$file")" >&2
+ return 1
+ fi
+ if [[ "$file" == *.html ]] && ! remote_loads "$file" >&2; then
+ echo "remote_loads: $file" >&2
+ return 1
+ fi
+ done
}
-@test "build joins the tags sorted and unique" {
- local line='Acquire::http::User-Agent "TurnKey APT-HTTP/1.3 (turnkey-core-19.0-trixie-amd64 proxmox lxc lxc)";'
- [ "$(tagid_build "$line")" = lxc-proxmox ]
+# inherit PAGE_SUFFIX
+# An htdocs directory left in /var by an older image or by somebody else:
+# the packaged files, with PAGE_SUFFIX appended to the index.
+inherit() {
+ mkdir -p "$(dirname "$HTDOCS")"
+ cp -R "$INITHOOKS_PATH/turnkey-init-fence/htdocs" "$(dirname "$HTDOCS")"
+ printf '%s\n' "$1" >> "$RENDERED"
}
-@test "is_tagged looks for the ajax host" {
- echo 'fence' > "$BATS_TEST_TMPDIR/index.html"
- run ! tagid_is_tagged "$BATS_TEST_TMPDIR/index.html"
- echo "$SCRIPTS" >> "$BATS_TEST_TMPDIR/index.html"
- tagid_is_tagged "$BATS_TEST_TMPDIR/index.html"
+# ------------------------------------------------ the detectors themselves
+
+@test "both detectors see the scripts an older image appended" {
+ inherit "$OLD_SCRIPTS"
+ run remote_loads "$RENDERED"
+ [ "$status" -eq 1 ]
+ [ "${#lines[@]}" -eq 2 ]
+ run remote_tokens "$RENDERED"
+ [ "${#lines[@]}" -eq 2 ]
}
-@test "render_scripts prints the two script tags" {
- run tagid_render_scripts iso 19.0-trixie-amd64 core
+@test "remote_tokens sees what HTMLParser does not" {
+ inherit '
-->'
+ run remote_loads "$RENDERED"
[ "$status" -eq 0 ]
- [ "$output" = "$SCRIPTS" ]
+ run remote_tokens "$RENDERED"
+ [ "$output" = "https://evil.example" ]
}
-# ------------------------------------------------------------------- hook
+@test "remote_tokens ignores links and page text" {
+ run remote_tokens "$EXPECTED"
+ [ -z "$output" ]
+ grep -q 'href="https://www.turnkeylinux.org' "$EXPECTED"
+}
-@test "hook does nothing under turnkey-init" {
- _TURNKEY_INIT=1 run "$HOOK"
+# ------------------------------------------------- the pages that are served
+
+@test "the packaged pages load nothing from another host" {
+ run serves_nothing_remote "$REPO/turnkey-init-fence/htdocs"
[ "$status" -eq 0 ]
- [ -z "$output" ]
- [ ! -e "$HTDOCS" ]
- [ -z "$(calls systemctl)" ]
}
-@test "hook copies the packaged htdocs when the writable copy is missing" {
+@test "the served pages load nothing from another host" {
run "$HOOK"
[ "$status" -eq 0 ]
- [ -f "$HTDOCS/index.html" ]
- run ! grep -q '@APP_NAME@' "$HTDOCS/index.html"
- grep -q 'core' "$HTDOCS/index.html"
- [ "$(tail -2 "$HTDOCS/index.html")" = "$SCRIPTS" ]
- # the packaged copy is left as shipped
- grep -q '@APP_NAME@' "$INITHOOKS_PATH/turnkey-init-fence/htdocs/index.html"
+ [ -s "$RENDERED" ]
+ run serves_nothing_remote "$HTDOCS"
+ [ "$status" -eq 0 ]
+}
+
+@test "the page served is the packaged page, named after the appliance" {
+ "$HOOK"
+ diff "$EXPECTED" "$RENDERED"
+ run ! grep -q '@APP_NAME@' "$RENDERED"
+}
+
+@test "a page inherited from an older image is replaced, not edited" {
+ inherit "$OLD_SCRIPTS"
+ "$HOOK"
+ diff "$EXPECTED" "$RENDERED"
+}
+
+# The review of the first version of this branch measured these getting
+# past a strip that removed double and single quoted '
+ ''
+ ''
+ '
'
+ ''
+ ""
+ ''
+ ''
+ '
-->'
+ '
'
+ '
'
+ )
+ local page
+ for page in "${pages[@]}"; do
+ rm -rf "$(dirname "$HTDOCS")"
+ inherit "$page"
+ [ -n "$(remote_tokens "$RENDERED")" ]
+ "$HOOK"
+ diff "$EXPECTED" "$RENDERED"
+ serves_nothing_remote "$HTDOCS"
+ done
+}
+
+@test "an inherited stylesheet and a file the package does not ship are replaced too" {
+ inherit ''
+ echo '@import url(https://evil.example/x.css);' >> "$HTDOCS/style.css"
+ echo 'alert(1)' > "$HTDOCS/extra.js"
+ "$HOOK"
+ diff "$INITHOOKS_PATH/turnkey-init-fence/htdocs/style.css" "$HTDOCS/style.css"
+ [ ! -e "$HTDOCS/extra.js" ]
+ diff <(cd "$INITHOOKS_PATH/turnkey-init-fence/htdocs" && ls -A) \
+ <(cd "$HTDOCS" && ls -A)
+}
+
+@test "the page keeps its own local stylesheet and image" {
+ "$HOOK"
+ grep -q 'href="/style.css"' "$RENDERED"
+ grep -q 'src="/turnkey-init-root.png"' "$RENDERED"
+ cmp "$INITHOOKS_PATH/turnkey-init-fence/htdocs/turnkey-init-root.png" \
+ "$HTDOCS/turnkey-init-root.png"
+}
+
+@test "the inline script that fills in the ssh address survives" {
+ "$HOOK"
+ grep -q 'document.getElementById("p1")' "$RENDERED"
+ grep -q 'document.getElementById("p3")' "$RENDERED"
+ # it is inline: the element that holds it has no src
+ run ! grep -qE '',
+ "single quoted script": "",
+ "unquoted script": "",
+ "upper case": '',
+ "script with a body": '',
+ "protocol relative": '',
+ "backslashes": '',
+ "slash backslash": '',
+ "scheme without slashes": '',
+ "tab in the scheme": '',
+ "leading space": '',
+ "entity encoded": '',
+ "stylesheet": '',
+ "preload": '',
+ "icon": '',
+ "image": '
',
+ "srcset": '
',
+ "picture source": '',
+ "iframe": '',
+ "object": '',
+ "embed": '