Conversation
The blueprint hash only covered the blueprint file itself, so a change to a file referenced through a `!File` tag - such as a Kubernetes Secret mounted into the container and then rotated - was never detected and the blueprint was not re-applied. Hash the referenced files' paths and contents alongside the blueprint's own content, in both places the hash is computed, so discovery and apply stay in agreement. Closes goauthentik#26289
5bdd88d to
79b0815
Compare
`File.__init__` assigns `self.path` from `loader.construct_object()` when the tag is built from a sequence node, so `!File [!Env P, default]` carries a tag object rather than a string. `Path()` raises `TypeError` on it, which the surrounding `except OSError` does not catch, and `blueprints_find` has no per-file guard - so one such blueprint would abort discovery for all of them. Skip those tags: their own content is already part of the blueprint content hashed above, and resolving them needs an entry and a blueprint that the hashing path does not have.
d5f2243 to
a2decb5
Compare
!File changes
Verification — NOT VERIFIED, changes requestedAdversarial verification at head The core fix is sound. The eight-case regression repro is 3 failed / 5 passed on base and 8/8 at head; Blocking finding: two uncaught exceptions abort discovery for every blueprintThe diff's own comment states the invariant:
Two inputs violate it. Both parse cleanly on base (so they are not YAML errors that
Cause 1 — Cause 2 — Blast radius: one bad blueprint takes out the whole scan
$ python probe-blastradius.py /agent-workspace/oss/authentik-base-arm
# arm: base (/agent-workspace/oss/authentik-base-arm)
mapping-node: scan completed, 3 of 3 blueprints hashed
nul-byte-path: scan completed, 3 of 3 blueprints hashed
$ python probe-blastradius.py /agent-workspace/oss/authentik-wt-verify
# arm: fixed (/agent-workspace/oss/authentik-wt-verify)
mapping-node: scan ABORTED after 1 of 3 blueprints -- AttributeError: 'File' object has no attribute 'path'
nul-byte-path: scan ABORTED after 1 of 3 blueprints -- ValueError: embedded null byteThe healthy blueprints stop being discovered. On base they are all hashed. That is a regression strictly worse than the bug being fixed, and it reaches both exported entry points:
Full attack transcript, three arms$ python probe-verify.py /agent-workspace/oss/authentik-base-arm
# arm: base
R-C MAPPING node : 98bab55ce2c902d6 stable=True
R-D empty sequence node : bd132b4d28a68de5 stable=True
R-E one-element sequence : 7fd1971f976796e3 stable=True
R-F path with a NUL byte : 36c03e4d98b4dc11 stable=True
$ python probe-verify.py /agent-workspace/oss/authentik-headcommit # UNGUARDED 79b0815eb
R-C MAPPING node : RAISED AttributeError: 'File' object has no attribute 'path'
R-D empty sequence node : RAISED IndexError: list index out of range
R-E one-element sequence : RAISED IndexError: list index out of range
R-F path with a NUL byte : RAISED ValueError: embedded null byte
$ python probe-verify.py /agent-workspace/oss/authentik-wt-verify # HEAD a2decb52d
R-C MAPPING node : RAISED AttributeError: 'File' object has no attribute 'path'
R-D empty sequence node : RAISED IndexError: list index out of range
R-E one-element sequence : RAISED IndexError: list index out of range
R-F path with a NUL byte : RAISED ValueError: embedded null byteR-D and R-E are NOT yours — Also measured and clean at head, no action needed: Suggested repair, measuredTwo edits, both in if not isinstance(getattr(tag, "path", None), str):
...
except (OSError, ValueError):$ python probe-repair.py /agent-workspace/oss/authentik-repair
===== repair-B: getattr + ValueError =====
R-C MAPPING node : 98bab55ce2c902d6 stable=True
R-F path with a NUL byte : e2ad8c56a9f1239d stable=True
--- regression repro still green? ---
8 passed, 0 failed
Rule
|
| Mutant | Repro | Killed by |
|---|---|---|
M1 except (OSError, TypeError), no isinstance guard |
8 passed, 0 failed | nothing |
M2 flat scan, no recursion into YAMLTag |
7 passed, 1 failed | test_file_tag_content_changed_nested |
| M3 hash contents but not the path | 8 passed, 0 failed | nothing |
M4 truthiness guard instead of isinstance |
crashes: AttributeError: 'Env' object has no attribute 'encode' |
— |
M1 and M3 are unpinned. M3 is ledger row R23, which the body closes by argument ("not independently observable"); M1 is the alternative the ## Fix section spends its longest paragraph rejecting. A row closed by argument rather than by an executed test is exactly where the defect hides. M2 and M4 are genuinely pinned.
R23 is testable, and the body's argument for why it is not is wrong in one direction: two blueprints whose own content is byte-identical but whose !File targets differ need different hashes. Construct it with two distinct paths whose files hold identical contents — the content digest cancels, the path digest does not.
Ledger rows checked and holding
R2 (!File "" → str, not skipped, falls to IsADirectoryError), R3 (int scalar is "123"), R5 (!Format path skipped), R18 (empty file's digest distinct from missing file's), R25 (two !File tags, order stable across parses) — all re-measured independently and all as the body describes. R4's control test_file_tag_path_from_tag does discriminate against the unguarded arm 79b0815eb as claimed; the three-arm framing is correct.
Verdict
Back to OSS Hunter. The bug, the fix's core and the test design are right; the diff introduces a crash on two inputs base survives, in a loop with no per-file guard, reachable from both entry points. Needed: the two-line repair above, a regression test per cause, an R23 test, and ledger rows for all three.
Rules: mutate-the-rejected-alternatives=covered(M1/M3 unpinned — findings above) | attribute-type-varies-by-constructor-branch=covered(R-C, the no-else branch of File.__init__) | unreachable-row-same-bytes=covered(R23 shown constructible) | private-fn-call-surfaces=covered(both blueprints_find and apply_blueprint walked; neither guards) | ledger-row-needs-its-fixture=covered(R2/R18/R25 measured, still fixture-less) | control-returns-its-own-input=unreachable(no test here asserts output identical to its input; the controls assert hash stability across two runs) | multi-assert-base-arm=unreachable(each repro case is its own check() with its own pass/fail line; no multi-assert body) | composed-transform-cross-product=covered(R-I, !File inside a !File default, measured stable) | dispatch-arm-boundary-coverage=covered(boundary inputs run against all three arms) | prior-art-recheck-at-gate=covered(re-run at the gate: blueprint hash File, 26289 in:body, blueprint_hash, blueprints_find all empty of competing PRs; issue goauthentik#26289 still open, no linked PR)
…overy The previous commit's guard reads `tag.path` to decide whether to skip the tag, but `File.__init__` assigns `self.path` only for scalar and sequence nodes; `path` is otherwise just a class annotation, which creates no attribute. A `!File` built from a mapping node takes neither branch, so the guard raises `AttributeError` before it can guard anything. Read the attribute with `getattr()` so the check cannot be what fails. `Path(...).read_bytes()` also raises `ValueError`, not `OSError`, for a path no syscall can accept, such as one containing a null byte, so widen the except to cover it. Both inputs parse cleanly and hash without complaint before this series, and neither `blueprints_find` nor `apply_blueprint` catches either exception, so a single such blueprint would stop every other blueprint from being discovered. Drop the separate digest of the path while here: the path of a tag that is hashed is a substring of the blueprint content already hashed above, so it cannot contribute anything the content digest does not.
Rework — head
|
Playwright e2eDownload this run's HTML report, traces and server logs, then open the report: gh run download 35815749118 -D playwright-artifacts
npx playwright show-report playwright-artifacts/playwright-report |
Verification — NOT VERIFIED at head
|
YAML anchors and aliases let a node be its own descendant, and PyYAML builds
collections in two steps precisely so such documents load. `iter_file_tags`
recurses with no bound, so hashing a blueprint like
context:
secret: &anchor
- *anchor
raises `RecursionError`. `blueprints_find` has no per-file guard around
`blueprint_hash` and `apply_blueprint` does not catch `RecursionError`, so one
such blueprint stops every other blueprint from being discovered - and the
document hashes without complaint before this series, so this would be a
regression rather than a pre-existing limitation.
Skip a node already on the path from the root. A node shared by two disjoint
routes - an ordinary blueprint reusing one anchor - is not its own ancestor, so
it is still walked from each route and its digest is unchanged; suppressing
those instead would silently move the hash of every aliased blueprint in the
wild and re-apply each one once.
Spell the widened `except` as `black` at the version pinned in pyproject.toml
formats it.
A node reached by two routes through one anchor is walked from each of them, so the file it references is folded into the digest twice, exactly as it is when the tag is written out twice. Bounding the walk by the ancestor chain keeps that; suppressing every node seen anywhere would fold it in once and move the hash of every blueprint that reuses an anchor. Compute the expected digest from the blueprint's own content rather than comparing two runs, so the test pins the count and not just stability.
Rework — both blocking findings addressed at
|
The stability cases read as annotations of a patch rather than as tests of the hash, which is not how the rest of the file reads.
Rework — head
|
…d cycle fold count
Verification, head
|
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
REQUEST CHANGES: this is not ready for the operator to submit because the accumulated test diff reads as generated. rule:reads-as-generated
Blocking — generated-test tells — authentik/blueprints/tests/test_v1_tasks.py:161-168,274-286
def write_blueprint(self, file, value: str):
"""Write a blueprint referencing \value` and return its hash as found on disk"""`
def assert_discovery_survives(self, reference: str):
"""Assert a blueprint referencing \reference` neither breaks its own hashing nor`
stops a healthy blueprint alongside it from being discovered"""
These are docstrings on test-local helpers, and the latter comments on what the test arrangement proves rather than documenting a reusable production behavior. Combined with the 281 added test lines for a 53-line production change, this makes the candidate read like an accumulated generated dossier rather than the surrounding test file's concise idiom. This is blocking under the candidate hygiene requirement: upstream review is likely to focus on this presentation rather than the small hash fix.
Failure scenario: an upstream maintainer reads the only changed test file and sees helper documentation plus prose-heavy test narratives that are absent from the existing 160-line file; the patch is rejected as over-engineered/generated before its regression coverage is evaluated.
def write_blueprint(self, file, value: str):
file.seek(0)
file.truncate()
file.write(f"version: 1\nentries: []\ncontext:\n secret: {value}\n")
file.flush()
return next(
found for found in blueprints_find() if found.path == Path(file.name).name
).hash
Remove the test-local helper docstrings and reduce the added cases/comments to the smallest set that demonstrates the production change, following the existing file's idiom.
What's good: I traced the base hash sites at tasks.py:144 and tasks.py:205 and the head's shared helper/call site, checked File.__init__'s scalar/sequence-only assignment, and read the full changed test file. The body includes all required facts-sheet sections, the base repro is independently consistent with the base source, no duplicate upstream PR was returned by the required searches, and the relevant fork unittest job passed. I did not run tests locally per review policy.
The tests covering the `!File` hashing added cases one per input, which made the test diff several times the size of the change it covers. Group the ones that differ only in the reference they hash into subtests, keeping every input, and drop the docstrings from the test-local helpers.
Rework — head
|
…able `!File` path
Verification — head
|
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: no blocking issues found; ready for operator submission.
I reviewed the complete two-file diff, the base implementation at 38fca6b34951852b53db575394a5fb0c546cdcd3, the candidate facts sheet and boundaries ledger, commit messages, policy guidance, and fork CI status. The new hash folds referenced-file content while preserving content-only hashing for invalid YAML; its ancestor-path traversal terminates recursive YAML without suppressing acyclic aliases. The added cases exercise direct/nested/aliased references, creation/removal/swapped content, malformed and unencodable paths, recursive structures, discovery continuity, and the apply path. The facts sheet contains the required sections and records executable base/head evidence and prior-art searches. The relevant Django test file has also run in fork CI as documented in the PR body.
CI is still in progress; the currently failed container/dependency jobs and pending matrix jobs were not treated as evidence against this Python-only change.
What's good: the latest two subtests specifically cover a constructible two-anchor cycle and a lone high surrogate, closing subtle exception and traversal boundaries without expanding the production patch.
Notes for the operator: upstream contribution guidance requires the usual matching Python lint/test checks; retain the documented CI/test evidence when preparing the upstream submission.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).
Verdict: NOT READY at 2453510bcf58f8823516d41c4eaf68ebff6f753f: the core fix is supported by the code, but the newline behavior change needs a regression test and the production commentary needs trimming.
Findings
Medium: pin the changed CRLF discovery/apply contract
authentik/blueprints/v1/tasks.py:209:
file_hash = blueprint_hash(content)This replaces sha512(path.read_bytes()).hexdigest() with hashing text read through universal-newline conversion. Concrete input: a valid blueprint written with CRLF and no !File. Its discovery hash changes even though no referenced file exists. Base discovery hashes CRLF bytes while retrieve_file() returns LF text for apply; the new helper makes them agree. This is beneficial, not a newly introduced infinite-reapply bug, but it is a separate behavior change and none of the added tests writes CRLF. The body's R26a explicitly leaves it untested. A maintainer should not have to rely on an external measurement for the changed shared-hash contract.
Suggested fix:
Add one real discovery/apply test using a binary-written CRLF blueprint,
without !File. Assert the discovered hash equals last_applied_hash after
application. Keep the CRLF bytes on disk and assert a subsequent discovery
still agrees. The equality must fail on base and pass with this change.
Low: remove patch-defense commentary, including an inaccurate fallback claim
authentik/blueprints/v1/tasks.py:104-106:
# and a blueprint. Hashing must never fail on a blueprint that can be loaded, so
# skip them; the tag's own content is part of the content hashed above. Read the
# attribute defensively - the check itself must not be what raises.This argues why the patch is correct instead of documenting the local runtime contract. The same tell appears in the helper docstring at lines 71-72 (routes is not its own ancestor and is still walked from each of them, as it is / without this check.) and the digest commentary at lines 117-118 (Only the referenced contents need digesting; the path itself is a substring of / the content already hashed above). Escaped YAML paths need not literally be substrings of the source text.
Also, lines 113-115 say a path containing a null byte means the tag resolves to its default value. Base File.resolve() at common.py:293-303 catches only OSError, not the ValueError raised for that path. Hashing tolerates this input; actual resolution does not necessarily fall back. Keeping that distinction explicit will prevent misleading future maintenance.
Suggested fix:
# Mapping-node tags have no path; nested tags cannot be resolved here.
path = getattr(tag, "path", None)
if not isinstance(path, str):
continue
try:
referenced = Path(path).read_bytes()
except OSError, ValueError:
# Unreadable references contribute only their blueprint source text.
continue
hasher.update(sha512(referenced).digest())Keep the cycle docstring to the runtime rule: skip ancestors, but visit aliases reached by distinct routes. Remove the comparison to an implementation without the check.
Independent break-it pass
I rebuilt these rows from the changed production code, then compared them with the body and every added test assertion. No local test suite was run.
| Changed predicate/guard/surface | Boundary and fixed behavior | Checked test coverage |
|---|---|---|
id(value) in ancestors |
Empty ancestor set admits root; direct/indirect cycles terminate; equal identity on another route is admitted | Discovery subtests for list, mapping and two-anchor cycles; exact fold-count subtests for cycle and alias |
isinstance(value, File) |
File yields; non-File does not; yielding does not terminate traversal | Direct rotation and nested Format rotation; nested File-in-File is not directly pinned |
isinstance(value, dict) |
Empty dictionary has no children; nonempty values traversed, not keys | All discovery documents traverse dictionaries; no dedicated empty-map assertion |
| `isinstance(value, list | tuple)` | Empty list stops; nonempty list traversed; tuple arm not constructed by these YAML tests |
isinstance(value, YAMLTag) / terminal return |
Other tags traverse attributes; null, zero, negative and maximum numeric scalars terminate, as do strings and unsupported collections | Format case pins tag traversal; version integer pins scalar exit; not every scalar subtype is separately tested |
except YAMLError |
Invalid YAML keeps content-only digest; empty/null content traverses nothing | Existing invalid-file test rejects before this helper in discovery; no new direct helper test for apply's invalid/empty input |
getattr(tag, "path", None) |
Missing attribute becomes None; present string preserved | Mapping-node discovery and stability cases; direct rotation |
not isinstance(path, str) |
Null/numeric/tag-valued sequence paths skip; scalar numeric text remains a filename; empty string reaches filesystem error handling | Tag-valued and mapping cases; empty-string/numeric variants not shipped as assertions |
except OSError, ValueError |
Missing/directory/permission/NUL/unencodable path skips; readable zero-byte file contributes a digest | Missing/created/removed, NUL and surrogate cases; readable empty file is measured in body, not pinned in new tests |
| Digest folding | Each readable occurrence contributes 64 bytes in traversal order; swapped contents differ | Exact cycle/alias counts and swapped-files test |
| Discovery and apply call replacements | Both now hash loaded text plus references; CRLF normalizes, unlike base discovery | Real reapply test for referenced-file rotation; CRLF equality unpinned (finding above) |
There is no new numeric threshold or index in production code, so equal-to-limit/one-past-limit boundaries are not applicable. Test-local selection found.path == Path(file.name).name selects the actual temporary blueprint, and range(routes) is exercised at one and two folds. Assertions for unchanged/unreadable files and discovery survival intentionally pass on the original base: they are compatibility controls, not evidence that rotation was fixed. All four rotation variants, appearance, removal, swapping, both exact fold counts and the final application hash inequality have a concrete reason to fail with content-only hashing. The preliminary successful-status assertion in the application test is setup validation, not the regression discriminator.
Maintainer fit and prior art
- I can confirm the reported bug statically from base: discovery's
sha512(path.read_bytes())cannot change when only an external secret changes. Both updated call sites now use the shared helper. I did not execute a reproduction. - The existing
test_v1_tasks.pyusesTransactionTestCase,CONFIG.patch, real temporary files and real discovery/application. The new tests follow that pattern instead of introducing fake task interfaces. Grouping reference variants in subtests makes the test additions substantially easier to review. - The touched module's recent history includes hidden-path handling #24222 and flaky-test work #19002. Recent merged outside contributions #26046 and #25993 use component-prefixed titles and tightly focused changes. The candidate title fits that convention once the fork-only prefix is removed. Neither example establishes a mandatory changelog addition; I am not inventing that requirement.
- Repeated upstream PR searches for
blueprint_hashand26289returned no matches. A broaderFile hashsearch returned unrelated work, not a competing fix. This is a search result, not proof of absence. - Tell pass: no em dash in the fetched changed source/tests, title or commit messages; no test-local helper docstrings, fake internal objects, or timer-based sequencing. The production patch-defense comments are the concrete remaining tell described above.
What's good: shared discovery/apply hashing, fixed-width digest contributions, defensive handling of malformed paths, and ancestor-local rather than global cycle tracking all make sense. Scope reviewed: both full changed files' diff, relevant base resolver/retrieval and test context, upstream history and external merged examples. CI at this head is still incomplete with failures and queued/running checks; historical test results are not treated here as a green current-head CI result.
SECOND READ: NOT READY — pin CRLF discovery/apply equality and remove the production patch-defense commentary.
Rework: head
|
Summary
.yamlfile, not the files it references through!Filetags. Rotating a Kubernetes Secret mounted into the container therefore left the hash identical, socheck_blueprint_v1_filenever dispatchedapply_blueprintand authentik kept serving the old value.blueprint_hash(content)inauthentik/blueprints/v1/tasks.py: the blueprint's own content, then the contents of every file it references through a!Filetag, each folded in as a fixed-length sha512 digest.iter_file_tags(value, ancestors), which walks the loaded blueprint and finds!Filetags including ones passed as arguments to another tag (!Format ["client-%s", !File /x]) — a flat scan of the top level would miss those. The walk skips a node already on the path from the root, because YAML anchors let a node contain itself; a node merely shared by two routes is not its own ancestor and is still walked from each, so no existing blueprint's digest moves.!Filewhose path is not a usable string, reading the attribute withgetattr(tag, "path", None).File.__init__assignsself.pathonly for scalar and sequence nodes, so a!Filebuilt from a mapping node has nopathattribute at all, and a sequence-node path built from a nested tag (!File [!Env P, default]) is a tag object. Resolving those needs an entry and a blueprint the hashing path does not have, and their own content is already in the digest.blueprints_find, which produces the value compared against the DB, andapply_blueprint, which stores it). They must agree or every discovery run would re-apply forever; that symmetry is why this is one shared helper.!Filetag hashes exactly as before when it has LF line endings, so the two literal sha512 digests pinned by the pre-existing tests do not move.test_valid_crlf: discovery used to hash the raw file bytes whileapply_blueprinthashed the text-mode read thatretrieve_file()returns, so a blueprint saved with CRLF line endings never matched its ownlast_applied_hashand was re-applied on every discovery run. Both sides now hash the same text, so they agree.Head of this branch:
18479bddc34eabab26995a951d5467d749339d9f. The production fileauthentik/blueprints/v1/tasks.pyis byte-identical from1c67effb1through3ad347d1b,e0bdb02a4,c4c91590aand2453510bc; the tenth commita3c2c6e81changes only its comments and docstrings (the module's AST with docstrings blanked is identical before and after, and every statement is unchanged), and the eleventh18479bddcis a test. Transcripts below that name3ad347d1b,e0bdb02a4orc4c91590awere run against that identical source and are labelled with the sha they were captured at. The probe transcripts in## Reproand## Test evidencewere re-run at this head. The issue's own scenario, both arms, run at this head:On base the three discriminating cases print the same digest twice — that identity is the bug.
Commits three to five on this branch answer two rounds of adversarial verification, the seventh (
e0bdb02a4) adds the three tests a third round asked for, the eighth (c4c91590a) folds the accumulated tests into the surrounding file's idiom at a gating review's request, the ninth (2453510bc) adds the two discovery inputs a fourth verification round found unpinned, and the tenth and eleventh (a3c2c6e81,18479bddc) answer a second-opinion review: comments trimmed to the runtime contract, and a CRLF discovery/apply test (round one, round two); see## Reworkat the end of this body for exactly what changed in each and why one line of the fix was deleted rather than tested.Upstream
goauthentik/authentik, default branchmain38fca6b34951852b53db575394a5fb0c546cdcd3enhancement/triage, reported against 2026.8.2, Kubernetes, no linked PR)authentik/blueprints/v1/tasks.py(blueprints_find,apply_blueprint, newblueprint_hashanditer_file_tags);authentik/blueprints/tests/test_v1_tasks.pyauthentik/blueprints/v1/common.py(class File, line 278; registered as!Fileat line 771)tasks.py+56/-4,test_v1_tasks.py+192/-0) — purely additive apart from the two call sites.Bug
A blueprint may pull values out of files at apply time:
blueprints_find()computedfile_hash = sha512(path.read_bytes()).hexdigest()— the bytes of the blueprint file only.check_blueprint_v1_file()re-applies a blueprint exactly wheninstance.last_applied_hash != blueprint.hash. The referenced file's contents are not in that hash, so when a mounted Kubernetes Secret is rotated the blueprint's meaning changes while its hash does not, and the hourlyblueprints_v1_discoverschedule (authentik/blueprints/apps.py:170) skips it forever. The watchdog path does not cover it either:BlueprintEventHandler.on_modifiedonly matches files whose path equals an instance's ownpath, so writes to the secret volume are ignored.Blast radius: any Kubernetes deployment using the Helm chart's
blueprints.secretsmechanism, i.e. the documented way to keep credentials out of blueprint files. Secret rotation silently does not take effect — the operator sees a successful rotation at the Kubernetes level while authentik keeps using the previous value, and only a manual re-apply fixes it (as the reporter found). Non-Kubernetes users of!Fileon any file that changes out of band are affected in the same way.Repro
Requires only a checkout; no database.
harness.pyconfigures a minimal Django settings module, stubs the threeauthentik.blueprints.v1.commonimports that need the app registry (each used only forisinstancechecks on paths the hashing code never reaches), imports the realFiletag andBlueprintLoader, and executes the hashing code from the realtasks.pysource. Against a base checkout there is noblueprint_hash, so it lifts base's own hash expression verbatim via regex — the same expression base compares againstlast_applied_hash.repro.pymirrors the seven non-ORM tests from the first two commits, eight checks in total (the extra two guard the pre-existing literal digests).repro-rework.pymirrors the four added by the third commit andrepro-rework2.pythe five added by the fourth; both three-arm transcripts are under## Test evidence. Files:/agent-output/oss/authentik/repro.py,repro-rework.py,repro-rework2.py,harness.py,probe-boundaries.py,probe-blastradius.py,probe-r23.py,probe-r23c.py,probe-mutants-rework.py,probe-recursion.py,probe-anchors.py,probe-alias-routes.py. Transcripts:base-arm.txt,unguarded-arm.txt,head-arm.txt,boundaries.txt,rework-transcript.txt,rw2-repro.txt,rw2-anchors.txt,rw2-alias-routes.txt,rw2-lint.txt.Fix
blueprint_hash(content)replaces the two baresha512(...)calls:Why this is the minimal correct change:
last_applied_hash != blueprint.hash), the schedule, the watchdog and the apply path are all untouched.blueprints_findguards only theload()call, and only forYAMLError;blueprint_hash(content)attasks.py:203is unguarded, so any exception it raises aborts the wholerglobloop and every other blueprint stops being discovered.apply_blueprint'sexcepttuple does not containAttributeError,ValueErrororRecursionErroreither. Every input that parses is therefore either hashed or skipped, never raised on — this is the invariant the third and fourth commits restore, and the the discovery-continues case table tests pin it at theblueprints_findlevel rather than at the helper.getattr(tag, "path", None)rather thantag.path: a guard cannot protect the expression that evaluates its own operand.pathis a class-level annotation onFile, which creates no attribute, and__init__assigns it in twoifbranches with noelse.isinstance(path, str)is a type guard, not a truthiness guard:!File ""has astrpath and must still be hashed (R2). A truthiness test would skip it, and on a tag-valued path it raises — mutant M4 below.except OSError, ValueError:Path(...).read_bytes()raisesValueError, notOSError, for a path no syscall can accept. Unreadable file →continue, deliberately not an error: the tag then contributes only its own source text, which is part ofcontentand already hashed. This is a statement about hashing, not about apply:File.resolve()(common.py:293-303) catches onlyOSError, so for anOSErrorit falls back to the tag's default, but aValueErrorpath (null byte, lone surrogate) raises there and is not resolved to the default. Hashing tolerates those inputs; resolution does not, on base or here. The unparenthesised spelling is whatblackat the version pinned inpyproject.toml:93produces, and what the repo already carries elsewhere (outposts/controllers/docker.py:76,kubernetes.py:52,enterprise/license.py:111).!Filein once where the walk folds it in twice, which silently changes the digest of every such blueprint in the wild and re-applies each one once. A depth cap would reject deeply nested blueprints that are perfectly valid; depth is measured as a control and is unaffected. Both alternatives were built and measured — see## Rework.blueprints_findrejects anyway.blueprints_findnow reads the file once intocontentand reuses it for both the parse and the hash, replacing a secondpath.read_bytes().strtaken from aScalarNode.value, so it is determined by the document text thatsha512(content.encode())already covers, although with YAML escapes and quoting it need not appear there literally. Ten input pairs, including escaped and differently quoted paths, gave the same verdict with and without a path digest (mutant M6 killed by nothing) — see## Rework.blueprints_findhashes thecontentit already read withopen(path, encoding="utf-8"), andapply_blueprinthashesinstance.retrieve(), which reads the same way (models.py:111). Keeping discovery onpath.read_bytes()(the rejected alternative, mutant M7) leaves a CRLF blueprint with a discovery hash that never equals the stored one;test_valid_crlfkills it.Alternatives rejected — each built as a mutant of the real
tasks.pyand killed by a named test (probe-mutants-rework.py,probe-recursion-repair.py, transcripts under## Test evidence):tag.pathdirectly (M1) — the guard raises on a mapping-node!File. Killed bytest_file_tag_unreadable_discovery_continues[path from a mapping].except OSErroralone (M2) — a null-byte path escapes asValueError. Killed bytest_file_tag_unreadable_discovery_continues[path no syscall can take].exceptto swallowAttributeError/TypeError(M3) — swallows genuine type errors anywhere in the loop, and only after the tag has been part-processed. Killed bytest_file_tag_unreadable_discovery_continues[path from a mapping].isinstance(M4) — skips a valid!File ""and raisesTypeErroron a tag-valued path. Killed bytest_file_tag_unreadable_hash_stable[path from a tag].Fileinstances (M5) — misses!Filenested inside!Format/!If/!Conditionarguments. Killed bytest_file_tag_content_changed[direct] [argument of another tag].RecursionErroron any cyclic anchor, aborting discovery for every blueprint. Killed bytest_file_tag_unreadable_discovery_continues[sequence containing itself],test_file_tag_unreadable_discovery_continues[mapping containing itself],test_file_tag_content_changed[direct] [reached through a cycle].test_file_tag_hashed_once_per_route[alias], which pins the fold count arithmetically.File.resolve()to resolve a tag path — a real second bug (it callsopen(self.path)on the tag object and raisesTypeError, which it does not catch), but it is a behaviour change to the apply path and a separate PR. Recorded as a follow-up; this PR only ensures discovery does not crash on it.sha512of the raw bytes and fold the references in on top (M7) — discovery and apply then hash different text for any CRLF blueprint, so it re-applies on every run. Killed bytest_valid_crlf(rw5-crlf-mutant.txt).blueprints_dir; mounted secret volumes live outside it, and Kubernetes secret updates are atomic symlink swaps the observer would need extra handling for. The hourly discovery run is already the intended reconciliation point.!Fileon every discovery — turns an hourly no-op into an hourly write for every such blueprint.Test evidence
Ten tests added to the existing
authentik/blueprints/tests/test_v1_tasks.py(the repo'sconvention is to extend the module's test file), plus two shared helpers (
write_blueprint,write_secret). Four of the ten drive several references throughsubTest, so the twenty-threedistinct inputs below are all exercised and each reports independently. The five
pre-existing tests in the file are untouched; fifteen tests on the branch in total.
The table is one row per input, which is the granularity
subTestreports and fails at.test_file_tag_content_changedtest_file_tag_content_changedtest_file_tag_content_changedtest_file_tag_content_changedtest_file_tag_createdtest_file_tag_removedtest_file_tag_contents_swappedtest_file_tag_hashed_once_per_routetest_file_tag_hashed_once_per_routetest_file_tag_unreadable_discovery_continuestest_file_tag_unreadable_discovery_continuestest_file_tag_unreadable_discovery_continuestest_file_tag_unreadable_discovery_continuestest_file_tag_unreadable_discovery_continuestest_file_tag_unreadable_discovery_continuestest_file_tag_content_unchangedtest_file_tag_unreadable_hash_stabletest_file_tag_unreadable_hash_stabletest_file_tag_unreadable_hash_stabletest_file_tag_unreadable_hash_stabletest_file_tag_unreadable_hash_stabletest_file_tag_applied_on_changec4c91590a)test_valid_crlf!File, two discovery runsprobe-crlf.py15e1a41b…≠ applied72405c18…, both runs)72405c18…); fork CICI - Mainrun35815749504at18479bddc(see## Verification method)A dash means the arm predates the behaviour the row is about and was not measured there.
This table is the only place the control/discriminating split is recorded: the tests
themselves are named and documented for what the hash is required to do, not for what they
proved about a patch, so nothing in the suite the maintainer keeps refers to this branch's
history.
Two things worth naming in that table. The three cycle-discovery inputs and the deeply-nested
control pass on base, because base does not walk the document at all — they pin a
regression the fix itself introduced, and they fail on the commit they were written against
(
44d6580f3), which is the arm that matters for them. Thetwo anchors containing each otherinput is the one a parent-only bound (compare against the immediate parent instead of the
whole ancestor chain) does not terminate on; the two self-containing inputs pass under that
mutant, this one raises
RecursionError(rv4-new-rows-mutants.txt). Thepath outside the filesystem encodinginput is a lone high surrogate, whichos.fsencoderejects with
UnicodeEncodeError, aValueErrorsubclass and not anOSError; it is thesecond input that reaches the
except OSError, ValueErrorhandler through a shape other thanthe null byte, and the
except OSErrormutant (M2) raises on it (rv4-r20b-mutant.txt). And thereached through a cycleinput of
test_file_tag_content_changedfails on both base (same digest twice, theoriginal bug) and the unbounded walk (
RecursionError, the regression), for two differentreasons.
The grouping is safe for the failing arms:
subTestreports each input separately, so aninput that raises does not hide the ones after it. Measured on this file, a loop whose first
input raises
AttributeErrorand whose second fails an assertion reportserrors 1 failures 1and still runs the third.The eleventh commit's test,
test_valid_crlf, through the realblueprints_findsourceand each arm's own
apply_blueprinthash expression (probe-crlf.py,rw5-crlf-arms.txt,rw5-crlf-mutant.txt). The blueprint is written as bytes with CRLF line endings and read backthe way
retrieve_file()reads it:test_valid_crlfassertsinstance.last_applied_hash == found.hashafter each of twoblueprints_discovery.send()runs, then that the file on disk still carries\r\n. On basethe first equality fails (discovery hashes the raw CRLF bytes, apply the LF text). The
raw-bytesmutant, which keeps the newblueprint_hashbut feeds discovery the raw bytes asbase did, fails the same way.
The console transcripts below are the output of the standalone probe scripts, which drive
the same inputs without a database. Their case labels are the probes' own and predate the
grouping; the mapping to the tests above is the
Inputcolumn.The fourth commit's five tests, three arms, verbatim (
rw2-repro.txt):The
shared-anchor digestline is the whole argument for the ancestor chain over a seen-set:5e4508957e9e504fat the unbounded arm and5e4508957e9e504fat head, i.e. byte-identical. A seen-set repair prints a different value there (913f8f538617c68bvs0b1f1fbc821d0b2cin the verification's own measurement of the two candidates), which is every aliased blueprint in the wild re-applying once.The fold count, pinned arithmetically (
probe-alias-routes.py,rw2-alias-routes.txt) — the expected digest is computed from the blueprint's own content plus N folds of the referenced file, so the arm is measured against arithmetic rather than against itself:Base folds in nothing (
0x), both fixed arms fold in exactly twice, and an aliased tag behaves identically to the same tag written out twice.test_file_tag_hashed_once_per_route[alias] is that measurement as a test.The third commit's four tests, three arms (
rework-transcript.txt). Each is named a control because it passes on base — base never touchestag.pathat all — and each fails on the commit it was written against:The earlier control
test_file_tag_unreadable_hash_stable[path from a tag] separates the first commit from the second; its failure on the unguarded arm79b0815eb(historical transcript, taken ata2decb52d):The stability controls (
test_file_tag_content_unchanged,test_file_tag_unreadable_hash_stable[missing file],test_file_tag_unreadable_hash_stable[deeply nested]) pass on every arm by design: they pin that the fix does not make the hash unstable, which would cause a re-apply every hour, so they cannot and must not fail on base.The blast-radius measurement, which is what makes these controls worth having — three blueprints in a directory, the bad one sorting in the middle, hashed in
blueprints_find's own order (probe-blastradius.py, historical transcript at44d6580f3; the cycle equivalent is thescanlines inrepro-rework2.pyabove):Mutants. Each alternative the
## Fixsection rejects, built against the realtasks.pyand run through the repros (probe-mutants-rework.py, historical transcript at44d6580f3):M6 is the control on the deletion: re-adding the path digest changes no test's verdict, which is the direct evidence that the line contributed nothing. Every other mutant is killed by a named test.
The seventh commit's three tests, both arms, mirrored in
probe-v3.py(V1, V2) andprobe-cycle-once.py, verbatim at this head (rv-v3-base.txt,rv-v3-head.txt,rv-cycle-base.txt,rv-cycle-head.txt). V3 to V6 are measurements the third verification round added; V6 walks a!Filenested in every other argument-taking tag:N4is the bound checked after the yield instead of before it: the!Fileon the cycle is yielded once more before the return, but the walk still terminates and the!Filebeside the cycle is folded in the same number of times, so no digest moves. It is an equivalent mutant on every input tried, not an uncovered one.N5(a depth cap instead of the ancestor chain) survived the branch's own tests and is whattest_file_tag_hashed_once_per_route[cycle] was written to kill:The expected digest in that test is computed arithmetically (content, then exactly one fold of the file), so a depth cap of 100 or 400 folds the file 100 or 400 times and misses it, while the ancestor chain folds it once.
The two candidate repairs for the cycle regression, each applied as a mutation of the real
tasks.py(the verification'sprobe-recursion-repair.py; REPAIR-B is what this branch shipped):Both terminate the cycles; only B leaves the shared-anchor digest where head has it.
test_file_tag_hashed_once_per_route[alias] is the test that kills A.Lint, with the repo's pinned
ruff==0.16.8(pyproject.toml:116) andblack==26.5.1(pyproject.toml:93, line-length 100) — the two commands CI runs (make ci-lint-ruff,make ci-lint-black), re-run at head18479bddc(rw5-lint.txt) with the same result as at3ad347d1b(rw2-lint.txt,v3-lint.txt):The pinned version matters here and cost a CI round: at
black25.9.0 the parenthesisedexcept (OSError, ValueError):passes, and at the pinned 26.5.1 it does not — 26.5.1 formats a handler with noasclause in PEP 758's unparenthesised form. The fork'slint (black, python)job was red at44d6580f3naming exactly that line; it is the pinned version that decides, and the repo already carries the unparenthesised spelling in three other files.Verification method
executedthroughout: the hashing logic locally on five arms, and the full Django test file by the fork's CI, which has the database this container lacks. At18479bddcthe local arms were re-run (repro.py3 failed / 5 passed on base, 8/8 at head;probe-crlf.pyDIFFER on base and on the raw-bytes mutant, AGREE at head).test_valid_crlfneeds the database; its fork CI result is recorded in the CI bullet below.executed: the three discriminating hash cases and five controls fromrepro.py, the four controls fromrepro-rework.py, the five cycle/alias cases fromrepro-rework2.py, the fold-count probe, the anchor probe, the blast-radius probe, the sixMmutants and the sixNmutants, on five arms (base38fca6b34, unguarded79b0815eb, second commita2decb52d, unbounded walk44d6580f3, head2453510bc, whosetasks.pyis identical to1c67effb1,3ad347d1b,e0bdb02a4andc4c91590a, and whose statements are identical to18479bddc, where only comments changed;repro.pyandprobe-crlf.pywere re-run at18479bddcitself), through the realFiletag, the realBlueprintLoaderand the realblueprint_hash/iter_file_tagssource fromtasks.py. Plus the nine boundary measurements inboundaries.txt. Runtime: CPython 3.14.7 in a venv at/agent-workspace/oss/akvenvwith Django 5.2.17, PyYAML 6.0.3, ruff 0.16.8, black 26.5.1 (the pinned version; an earlier round ran 25.9.0, which does not reproduce CI's formatting verdict).executedby fork CI rather than locally:test_file_tag_applied_on_change,test_valid_crlfand the five pre-existing tests in the file were not run in this container.TransactionTestCase+blueprints_discovery.send()need postgres and the dramatiq broker; this container has no postgres (psql,postgres,initdb,redis-serverall absent). The logic traced line by line:blueprints_discovery→blueprints_find()→check_blueprint_v1_file→last_applied_hash != blueprint.hash→apply_blueprint, which storesblueprint_hash(instance.retrieve()).write_blueprint()matches onfound.path == Path(file.name).namebecauseBlueprintFile.pathis set fromstr(rel_path)relative toblueprints_dir(tasks.py:185,204) andNamedTemporaryFile(dir=TMP)puts the file directly in that directory; the discovery-continues case table uses the same property. The fork'stest-unittest - PostgreSQL 14-alpine - Run 10/10andPostgreSQL 18-alpine - Run 10/10shards atc4c91590a(jobs106755883027/106755883201, run35730715559,CI - Mainatc4c91590a5c80d561706292fed262f80e1652edc, the last head at which the matrix had finished when this body was written) executed the whole file under the real runner with a database; every test in it, sorted, identical on both shards (rv4-ci-testfile.txt); the ninth commit adds twosubTestinputs totest_file_tag_unreadable_discovery_continuesand no test:So
test_file_tag_applied_on_changedoes observelast_applied_hashmoving throughblueprints_discovery.send(), and the two literal digests intest_valid/test_valid_updatedare unchanged under the real runner.gh pr checks 1 --repo sprayberry-code/authentikatc4c91590a(run35730715559,CI - Main, completed;rv4-ci-jobs.txt), a head at which the full matrix had finished; the ninth commit2453510bcchanges onlytest_v1_tasks.py(twosubTestinputs), andtasks.pyis byte-identical between the two. At the current head18479bddcthe matrix isCI - Mainrun35815749504; its state when this body was written is in the first line of this bullet's list below. Every Python lint job passes atc4c91590a(lint (black, python),lint (ruff, python),lint (bandit, python),lint (mypy, python),lint (pending-migrations),lint (check)), and all twentytest-unittest - PostgreSQL 14-alpine|18-alpine - Run N/10shards pass. One line per non-green job atc4c91590a:18479bddc(CI - Mainrun35815749504): pending when this body was written (2026-09-23T03:5xZ): thetest-unittest - PostgreSQL 14|18-alpineshards that executetest_valid_crlfhad not yet run. Completed so far:build-compute-tags,test-make-seedandlint (bandit, python)pass; thebuild (ldap|rac|radius, *.Dockerfile)image jobs,build-container,dependency-reviewandci-website-markfail for the fork-infrastructure reasons below, as at every earlier head. Locally at this headblack26.5.1,ruff checkandruff format --checkare clean (rw5-lint.txt). The Scout or the next seat should readgh pr checks 1 --repo sprayberry-code/authentikfor the shard results before relying ontest_valid_crlfas executed.lint (black, python)—pass(2m1s). This is the job that was red at44d6580f3namingauthentik/blueprints/v1/tasks.py, and it is the CI-side confirmation of the fix in R20c.lint (ruff, python)(2m36s),lint (bandit, python)(2m26s),lint (mypy, python)(4m29s),lint (pending-migrations, python,runtime)(2m58s),lint (check),lint (clippy, rust),lint (cargo-deny, rust),lint (rustfmt, rust-nightly),lint (spellcheck, node),lint (catalogs, node),lint (oxlint-fixtures, node),lint-golint,build-docs,build-integrations,build-compute-tags,check-changes-applied,test-integration,test-migrations, all tentest-migrations-from-stableshards,e2e (playwright),test-unittest(the Go outpost suite, 6m46s) and all twentytest-unittest - PostgreSQL 14-alpine|18-alpine - Run N/10shards pass.build-container, and thebuild (ldap|proxy|rac|radius|server, *.Dockerfile)matrix — fail, not this diff: each dies pushing an image to the upstream's registry namespace,denied: permission_denied: The requested installation does not existonghcr.io/goauthentik/dev-docs:gh-gh-<sha>(transcript captured at1c67effb1; the same jobs fail identically at this head). A fork's token cannot push there; this is fork infrastructure and is independent of a Python-only change.dependency-review— fail, not this diff: it compares dependency manifests against the fork's own base and fails on every PR in this fork. No dependency file is touched by this branch.ci-core-mark,ci-website-mark— fail, not this diff: these are aggregate gates that require every job in their run to be green, so they inherit the image-build anddependency-reviewfailures above.test-openid-conformance (ssf_transmitter)— fail, not this diff: therun conformancestep (step 8) succeeded; the job fails at step 9,.github/actions/test-results, whose codecov upload dies withError: Failed to get ID Token(OIDC token unavailable to a fork's workflow token). The same job passed ate0bdb02a4, and no OIDC or conformance file is touched by this branch.c4c91590a. Every non-green job above is fork infrastructure; every job that executes Python, lints Python, or runs the blueprint tests is green there.Prior art
Re-run at head
18479bddc(2026-09-23T03:5xZ): the four searches marked "re-run at this head" still return nothing, issue goauthentik#26289 is still OPEN with labelsenhancement/triage, andgit log 38fca6b34..origin/main(upstreammainat449f29969) has no commit touchingauthentik/blueprints/v1/tasks.py, none mentioning26289, and none adding or removingread_bytesunderauthentik/blueprints. Earlier pass atc4c91590a(2026-09-22T13:04Z,rw4-priorart.txt), because the candidate is scoped on a public open issue and hours had passed since the previous pass. This is the sixth pass; every earlier one returned the same:gh search prs --repo goauthentik/authentik "blueprint_hash" --limit 20gh search prs --repo goauthentik/authentik "iter_file_tags" --limit 20gh search prs --repo goauthentik/authentik "blueprint hash File" --limit 20gh search issues --repo goauthentik/authentik "26289 in:body" --limit 20gh issue view 26289enhancement/triage, no linked PRgh api repos/goauthentik/authentik/issues/26289/timelinefiltered tocross-referencedgh search prs --repo goauthentik/authentik "blueprints_find" --limit 20website/docs: explain guarantees around blueprint ordering(merged 2024-07-30, docs only)gh search prs --repo goauthentik/authentik "blueprint File tag hash"gh search prs --repo goauthentik/authentik "blueprint re-apply referenced file secret"gh search prs --repo goauthentik/authentik "check_blueprint_v1_file"gh search issues --repo goauthentik/authentik "blueprint secret rotate re-apply"gh search prs --repo goauthentik/authentik "blueprints"(control — proves the search works)None of those eight touches the hashing or discovery path. The closest by title, goauthentik#25464 "blueprints: handle absent blueprint references", is about references between blueprint entries, not
!File. Issue goauthentik#26289 is open with no linked PR; this PR cites it and does not attempt to resolve anything else in it.git log --oneline -20 -- authentik/blueprints/shows the accepted shape here: small, single-purpose fixes with a test in the module's existing test file (672200757 blueprints: fix ignoring hidden paths,faf2211cc blueprints: Fix tags not being resolved when used for !Env's default value,1bab19af3 blueprints: fix YAMLTag repr raising on unresolved tags). The!Envone is the same class as this: a tag whose behaviour was incompletely handled.Policy
AI_POLICY.md(root,main) — verbatim:Blocking operator steps before submitting upstream, both from that policy:
## Disclosure factsbelow — write it in your own words; the policy requires a human to have reviewed and edited the text.## Bugand## Fixsections are the whole argument.No commit trailer is required by this repo (no DCO, no
AI-Assisted:tag), so no amend/rebase step is needed before submitting.AGENTS.md:136says the opposite of a required trailer: "Commit attribution: do not add a Claude co-author trailer; credit human collaborators instead." — the branch's five commits carry no attribution trailer of any kind, which complies..github/pull_request_template.md(verbatim checklist):Ran here:
ruff checkandblack --checkwith the pinnedruff==0.16.8andblack==26.5.1— the two commands CI's lint matrix runs for Python (make ci-lint-ruff,make ci-lint-black), both clean (transcript above). Not run:make allandmake docs—make allbuilds the Go/Rust/TypeScript subtrees and runs the full Django suite against postgres, none of which exists in this container. No docs change is needed:website/docs/customize/blueprints/v1/tags.mdxdocuments!Fileas "Returns the contents of the file at the given path", which is what the code does; this PR changes when a blueprint is re-applied, not what!Filereturns. The template's body sections (What does this PR change?,Why is this change needed?,How was this tested?,Linked issues) need filling; userefs #26289orcloses #26289per the template's own note.CONTRIBUTING.mdis a three-line stub pointing athttps://docs.goauthentik.io/docs/developer-docs/.AGENTS.mdis a repo map; beyond the attribution line above it contains no AI restriction. No CLA, no DCO sign-off..github/AI_POLICY.md,AI.md,AGENT_POLICY.md,.github/CONTRIBUTING.mdall return 404.Commit style on
mainis<subsystem>: <lowercase imperative>(blueprints: fix ignoring hidden paths); all five commits on this branch follow it.Disclosure facts for the operator
Plain facts, for you to turn into the disclosure the policy requires. Not a draft body.
authentik/blueprints/v1/tasks.pyandcommon.pydirectly — the issue names the symptom, not the cause; the cause (hash coverage) came from the source.blueprint_hash,iter_file_tags, the guard, the ancestor-chain bound, the two call-site changes, and all ten added tests covering twenty-three inputs.TypeError(second commit), the mapping-nodeAttributeErrorplus null-byteValueError(third commit), and the cyclic-anchorRecursionError(fourth commit), each with tests that fail on the commit they were written against.ValueErrorshape) with the fix already correct on both, and added the two inputs.2453510bcasked for the CRLF behaviour change to be pinned and for the production comments to be cut back to the runtime contract; the AI wrotetest_valid_crlf, measured it through the hashing path on base, head and a raw-bytes mutant, and rewrote the comments (the first comment on theexcepthad claimed an unreadable path resolves to the default, which is not true forValueErrorpaths).## Prior art(five times — while hunting, at the first gate, at the second, at3ad347d1b, and at this head) and the policy-file reads in## Policy.ruff/blackcommands; those transcripts are real copied output, not reconstructed.make all, ormake docslocally — no postgres in its container. The Django test file was executed by the fork's GitHub Actionstest-unittestmatrix atc4c91590a, whose log is quoted verbatim above;test_valid_crlf(the last commit) was executed locally through the same hashing path on base, head and a mutant, and as a Django test only by fork CI.Boundaries
One row per predicate, comparison, guard or index the diff adds or changes. Measurements are from
boundaries.txt,rework-transcript.txt,rw2-repro.txt,rw2-anchors.txtandrw2-alias-routes.txtunless stated.isinstance(path, str)pathis a normalstrtest_file_tag_content_changed[direct]pathis the empty string!File ""str, so not skipped:Path("")→IsADirectoryError(anOSError) →continue. Measured stable. A truthiness guard would skip it instead — the reason this isisinstance, notif pathpathis a YAML int scalar!File 123ScalarNode.valueis always astr, sopathis"123", not anint— not skipped. Measured identical on all arms (2f26bdedf318319d)pathis an!Envtag (!File [!Env P, d])TypeError, uncaught, abortingblueprints_findfor every blueprinttest_file_tag_unreadable_hash_stable[path from a tag] (control); fails on79b0815ebpathis a!FormattagTypeError ... not 'Format', stable at headgetattr(tag, "path", None)!File {a: b}— a mapping node, soFile.__init__takes neitherifbranch and assigns nothing;pathis only a class annotationNone→ skipped, hash stable, scan continues. Readingtag.pathdirectly raisesAttributeErrorinside the guardtest_file_tag_unreadable_discovery_continues[path from a mapping],test_file_tag_unreadable_hash_stable[path from a mapping]; mutants M1, M3!Filewhosepathattribute exists and is astrgetattrreturns it unchanged; identical totag.pathisinstance(value, File)Filetagtest_file_tag_content_changed[direct]YAMLTagwith no nestedFile(!Env X)vars()walked, nothing foundplain blueprint hash unchanged(control) + R10Fileand contains nested tagsif/elifchain is deliberately separate from the yieldtest_file_tag_content_changed[direct] [argument of another tag]isinstance(value, dict){}(empty mapping).values()empty, loop body never runs, no crashplain blueprint hash unchanged(control)isinstance(value, list | tuple)[](empty sequence)plain blueprint hash unchanged(control) — the blueprint'sentries: []strvalueelse: return; not iterated char-by-char, becausestris matched by neitherdictnorlist | tupleversion,name)isinstance(value, YAMLTag)!Format ["client-%s", !File p]vars()yieldsformat_stringandargs; theFileis found insideargstest_file_tag_content_changed[direct] [argument of another tag]; mutant M5else: returnNone,int,bool,float(YAMLnull,1,true)plain blueprint hash unchanged(control) —version: 1is anintid(value) in ancestorssecret: &a\n - *a)RecursionError, uncaught byblueprints_findorapply_blueprint, aborting the whole scan (measured: 3 of 3 hashed on base, 1 of 3 at44d6580f3)test_file_tag_unreadable_discovery_continues[sequence containing itself],test_file_tag_unreadable_discovery_continues[mapping containing itself],test_file_tag_content_changed[direct] [reached through a cycle]; all three fail on44d6580f3!File(&a [*a, !File p])test_file_tag_content_changed[direct] [reached through a cycle] (fails on base with equal digests, and on44d6580f3withRecursionError)one: &a [!File p],two: *a)44d6580f3(5e4508957e9e504fon both) and to the same tag written out twice. A global seen-set would fold it once and move the digest of every aliased blueprint in the wildtest_file_tag_hashed_once_per_route[alias] (expected digest computed arithmetically),test_file_tag_content_changed[direct] [reached through an alias]; kills REPAIR-Atest_file_tag_unreadable_hash_stable[deeply nested] (control: passes on base,44d6580f3and head)&a → *bwith*bwritten first), which PyYAML's composer rejects as a forward alias. Six orderings measured (probe-rows.py,rv4-rows-head.txt): both-sequences either order, both-mappings and a merge key are allComposerErroron both arms, but an inner anchor defined inside the outer one and aliased after it (&outer [{inner: &inner [*outer]}, *inner]) loads on both arms. It is a cycle of length two: neither node contains itself directly, so a bound that compares only against the immediate parent does not terminate on it (mutant N7,RecursionError), while the ancestor chain does. Same defect class as R14: a claim about the loader's construction order, made from the shape rather than from the librarytest_file_tag_unreadable_discovery_continues[two anchors containing each other] (fails on44d6580f3and on N7; passes on base, which does not walk)&a [*a, !File p]: the!Filebeside the self-referencetest_file_tag_hashed_once_per_route[cycle]; mutants N5, N6 (both killed by nothing before this test)ancestorsdefaultancestorsomittedfrozenset(), so nothing is suppressed at the root. The default is immutable and rebound (ancestors = ancestors | {...}), never mutated, so it cannot leak state between calls. Measured (probe-rows.py): the same document hashes identically before and after a cyclic document is hashed in between, the digest still tracks the referenced file on the third call, and the generator yields the tag on every call ([1, 1, 1]; base[0, 0, 0])rv4-rows-head.txt); every hashing test callsiter_file_tagswith one argumentload(content, BlueprintLoader){)YAMLError→ content-only digest, matching baseinvalid yaml hash unchanged(control)"", a comment-only document, an explicitnulldocument, whitespace onlyNone;iter_file_tags(None)hitselse: return; content-only digest, byte-identical to base for all four (rv4-rows-head.txt/rv4-rows-base.txt:cf83e135…,df1ea83e…,811da511…,786f7ef1…on both arms). Unreachable fromblueprints_find(falsyraw_blueprintskipped before hashing) but reachable fromapply_blueprintvia an emptyinstance.contentprobe-rows.py, both arms);invalid yaml hash unchangeddrives the sameelse: returnPath(path).read_bytes()OSError→continue; hash stable across runstest_file_tag_unreadable_hash_stable[missing file] (control)test_file_tag_removed(fails on base with equal digests)b"")sha512(b"")folded in — measured distinct from the missing-file digest, because the missing case folds in nothing at allTrue)IsADirectoryErroris anOSError→continue. Measured stablePermissionErroris anOSError→continueexcept OSError, ValueError!File "\0")ValueError: embedded null byte— not anOSError— caught →continue, scan survivestest_file_tag_unreadable_discovery_continues[path no syscall can take],test_file_tag_unreadable_hash_stable[path no syscall can take]; mutant M2ValueErrorfromread_bytescontinuepath. The row previously read that only the null byte failsos.fsencode; that is wrong. A lone high surrogate (!File "\ud800", a valid YAML escape thatScalarNode.valuecarries as a one-characterstr) cannot be encoded undersurrogateescapeand raisesUnicodeEncodeError, aValueErrorsubclass and not anOSError. Caught by the shipped handler; theexcept OSErrormutant (M2) raises on it. A lone low surrogate (\udcff) is thesurrogateescapebyte form and reaches the filesystem asFileNotFoundError, anOSError. Measured on both arms and both mutants (rv4-r20b-mutant.txt,rv4-r20b-surrogate.txt)test_file_tag_unreadable_discovery_continues[path outside the filesystem encoding] (control: passes on base; fails on79b0815eb/a2decb52dwithUnicodeEncodeError)blackat the pinned26.5.1reformats the parenthesised form; semantically identical. The parenthesised form made CI'slint (black, python)red at44d6580f3black --checkat the pinned version (transcript above)test_file_tag_createdtest_file_tag_content_changed[direct]sha512(content.encode())already covers. Measured unobservable as a separate contribution: ten input pairs (distinct paths with identical contents, distinct missing paths, swapped tag order, YAML escapes and quoting styles that make the path string differ from the document bytes, sequence-node vs scalar paths) all give the same verdict with and without a path digest, and mutant M6 re-adding it is killed by nothing. The line was therefore deleted rather than testedprobe-r23.py,probe-r23c.py, mutant M6; the behaviour itself bytest_file_tag_content_changed[direct]!Filetags whose content bytes could concatenate ambiguouslyupdateis exactly 64 bytes!Filetags in one blueprinttest_file_tag_hashed_once_per_route[alias] pins the count!Filetags whose files swap contentssha512(A)+sha512(B)andsha512(B)+sha512(A)are distinct inputs. On base the hash is identical before and aftertest_file_tag_contents_swapped(fails on base with equal digests)blueprints_find!Fileat allplain blueprint hash unchanged(control) + the untouched literal digests intest_valid/test_valid_updated, executed by fork CIblueprints_find!Filepath.read_bytes()at discovery (raw bytes, CRLF kept) butblueprint_content.encode()at apply, whereretrieve_file()reads text mode and normalises to LF, so for a CRLF blueprintlast_applied_hashnever equalled the discovery hash and it re-applied on every scan. Head hashes the text-modecontenton both sides, so the two agree. Measured (probe-v3.pyV3): discovery72405c1803c640f4, apply72405c1803c640f4, base raw-bytes15e1a41b4b30fd69. A CRLF blueprint re-applies once more after upgrade and then stops; it was re-applying every scan before. The file on disk keeps its CRLF bytes; only the hashed text is normalisedtest_valid_crlf(fails on base: discovery ≠last_applied_hash; kills mutant M7, raw-bytes discovery); measuredrw5-crlf-arms.txt,rw5-crlf-mutant.txtblueprints_findlooptest_file_tag_unreadable_discovery_continues[path from a mapping],test_file_tag_unreadable_discovery_continues[path no syscall can take],test_file_tag_unreadable_discovery_continues[sequence containing itself],test_file_tag_unreadable_discovery_continues[mapping containing itself],test_file_tag_unreadable_discovery_continues[two anchors containing each other],test_file_tag_unreadable_discovery_continues[path outside the filesystem encoding];probe-blastradius.py,repro-rework2.pyapply_blueprintblueprint_hashon the same text-mode read; a mismatch would re-apply every hourtest_valid_crlf(asserts the equality directly, twice),test_file_tag_applied_on_change(database tests, executed by fork CI)Known follow-up, deliberately not in this PR (one bug per PR):
!File []and!File ["p"]raiseIndexErrorinsideFile.__init__at parse time, onmainbefore this change (probe-loadonly.pyconfirmsUNCAUGHT AT PARSEon the base arm). Separately,File.resolve()callsopen(self.path)on a tag-valued path and raisesTypeError, which it does not catch. Both are pre-existing and out of scope; R4/R5/R5a keep discovery working in their presence without fixing them.A note on R27, because two rounds of verification have now found a regression there.
blueprints_findhashes without a per-file guard, so any exception fromblueprint_hashcosts the entire scan rather than one blueprint. This PR keeps the invariant by making the hashing path total for every input that parses, which is the minimal change. Wrapping the per-blueprint hash in atrywould be a defence-in-depth improvement toblueprints_finditself — a different change, with a different blast radius, and one an upstream maintainer should decide on separately.Rework
Six rounds, ten commits after the first.
Round one — verification comment, answered by the third commit
44d6580f3. It found the second commit's guard raising on inputs base hashed without complaint:getattr(tag, "path", None)— the blocking finding.if not isinstance(tag.path, str)evaluatestag.pathbefore it guards anything, andFile.__init__assignsself.pathin twoifbranches with noelse, so a mapping-node!Filehas no such attribute. Reproduced, fixed, pinned by two tests and mutants M1/M3.except (OSError, ValueError)— second cause. A null byte in the path makesread_bytes()raiseValueError. Pinned by two tests and mutant M2.!Filetarget paths whose files hold identical contents". That fixture was built and it does not discriminate, because two blueprints with different paths do not have identical content: the path text is part of the content. Ten pairs were tried, including cases where the digested path string is deliberately not a substring of the document (!File "\x61b"vs!File ab, quoting variants, sequence-node paths) — every pair gives the same verdict with and without the line, and mutant M6 is killed by nothing. R23 was unobservable because the line was redundant, so the honest resolution is deletion.Round two — verification comment, answered by the fourth and fifth commits (
ed629ce2d,1c67effb1) and a sixth (3ad347d1b). Two blocking findings, plus one the rule book caught before the seats did:BlueprintLoader". It is: YAML anchors and aliases make a node its own descendant and PyYAML's two-step construction exists so such documents load.iter_file_tagsrecursed with no bound, soblueprint_hashraisedRecursionError— uncaught byblueprints_find(which guards onlyload(), only forYAMLError) and absent fromapply_blueprint'sexcepttuple, so one such blueprint aborted the entire scan: 3 of 3 hashed on base, 1 of 3 at44d6580f3. That is strictly worse than the bug being fixed, on input that works onmaintoday. Fixed by bounding the walk on the ancestor chain, with five tests plus a sixth pinning the fold count. The verification measured two candidate repairs and this branch shipped the one it recommended: a global seen-set also terminates cycles but folds a shared acyclic subtree in once instead of twice, silently changing the digest of every blueprint that reuses an anchor. R14 is rewritten from the measurement, and R14a–R14e are new rows for the cases it hid.lint (black, python)was red on this diff's own line.blackis pinned at26.5.1(pyproject.toml:93) andmake ci-lint-blackrunsblack --check; at that version a handler with noasclause takes PEP 758's unparenthesised form. The container had been running 25.9.0, which passes the parenthesised spelling — the pin is the whole difference. Fixed toexcept OSError, ValueError:, which the repo already uses in three other files, and the localblackis now the pinned version so this cannot recur silently. New ledger row R20c.test_v1_tasks.pyended in(control), which is verification bookkeeping: it tells a reader what the case proved about a diff, not what the hash is required to do. The sixth commit rewrites those eight docstrings and changes nothing else —git diff 1c67effb1 3ad347d1b -- authentik/blueprints/v1/tasks.pyis empty, and the test bodies are untouched. Which cases are controls, and what each controls for, is recorded in the## Test evidencetable below, where it belongs.Round one's verification also confirmed rows R2/R3/R5/R18/R25 independently and identified
!File []/!File ["p"]IndexErroras pre-existing at parse time on base — recorded as a follow-up above, not fixed here. Round two re-confirmed R23's deletion with five further pairs including non-substring paths, and settled that row.Round three — at
3ad347d1b, answered by the seventh commite0bdb02a4, which is tests only (git diff 3ad347d1b e0bdb02a4 -- authentik/blueprints/v1/tasks.pyis empty; 50 insertions in the test file, zero deletions). The fix itself held: the core hash, the getattr guard, the except tuple and the ancestor-chain bound all survived the attack, and a!Filenested in every other argument-taking tag (!Env,!Context,!If,!Condition,!Find,!AtIndex,!Enumerate) is found and folded in. Three holes in the ledger, none a defect in the fix:test_file_tag_removed: on base the digest before and after deletion is the same.test_file_tag_contents_swapped: on base the digest is the same before and after the swap.if len(ancestors) > 100: return(N5) or> 400(N6), so the choice of the ancestor chain over a depth cap, argued in R14c, was unpinned.test_file_tag_hashed_once_per_route[cycle] computes the expected digest with exactly one fold of the file beside a self-referencing node; a depth cap folds it once per level and fails. Row R14f.Round three also measured R26a: the CRLF discovery/apply agreement, a pre-existing mismatch on base that this diff happens to close because both call sites now hash the same text-mode
content.Round four — gating review
at
e0bdb02a, answered by the eighth commitc4c91590a. The finding was presentation, notbehaviour: the accumulated test diff read as generated. 281 added test lines for a 53-line
production change, docstrings on the two test-local helpers, and one test per input where the
surrounding file has none of that.
The eighth commit is tests only —
git diff e0bdb02a c4c91590a -- authentik/blueprints/v1/tasks.pyis empty. The four families that differ only in which reference they hash (content rotation,
fold count, hash stability, discovery survival) become four tests driving their inputs through
subTest, the helper docstrings are gone, and a second one-line helper (write_secret) removesthe seek/truncate/write/flush repetition the cases shared. Every one of the twenty inputs is
kept — verified mechanically by extracting the reference literals from both revisions of the
file and diffing the sets (
rw4-inputs.py: nothing dropped). The test diff is now 170 addedlines, and the whole file is 328 lines against base's 158.
Both arms re-measured at this head, unchanged from
e0bdb02a:repro.py3 failed / 5 passed onbase and 8/8 at head;
repro-rework.py4/4 on both arms (all four are controls);repro-rework2.py3 passed / 2 failed on base and 5/5 at head.
black --checkat the pinned26.5.1andruff checkare both clean on the touched files.Round five — verification at
c4c91590a, answered by the ninth commit2453510bc, which istests only (
git diff c4c91590a 2453510bc -- authentik/blueprints/v1/tasks.pyis empty; twolines added, both
subTestinputs oftest_file_tag_unreadable_discovery_continues). Theround ran the probe a cut-off segment had left unexecuted against the four ledger rows still
closed by prose:
rejected by the composer" had been measured with one ordering. An inner anchor defined
inside the outer one and aliased after it,
&outer [{inner: &inner [*outer]}, *inner],loads on both arms. Head hashes it; the unbounded walk raises
RecursionError; and aparent-only bound, which the two self-containing inputs cannot tell from the ancestor
chain, raises too. Pinned as the
two anchors containing each otherinput.ValueErroros.fsencodecan raise: a lone high surrogate raisesUnicodeEncodeError.The shipped handler catches it;
except OSErroralone does not. Pinned as thepath outside the filesystem encodinginput, marked a control in the table because basenever reads the path.
frozensetdefault across calls) hold and arenow measured rather than argued.
black --checkat the pinned26.5.1andruff checkare clean on the touched file.Round six — second-opinion review at
2453510bc(SECOND READ: NOT READY), answered by the tenth commita3c2c6e81(comments only)and the eleventh
18479bddc(one test). The gating review at the same head had approved.test_valid_crlfwritesa CRLF blueprint as bytes, runs discovery twice, and asserts after each run that the
stored
last_applied_hashequals the discovered hash, then that the file still has CRLF ondisk. Base: discovery
15e1a41b…, applied72405c18…, different on both runs. Head: both72405c18…. Raw-bytes discovery mutant (M7): different on both runs.exceptcommentsaid an unreadable path "resolves to its default value";
File.resolve()catches onlyOSError, so aValueErrorpath does not. The comments now state the local contract inone line each, and the two docstrings drop the comparison with an implementation without
the check. The module's AST with docstrings blanked is identical before and after this
commit: no statement changed.
## Fixnow states the resolve distinction explicitly.repro.pyre-run at18479bddc: 3 failed / 5 passed on base, 8/8 at head.black --checkatthe pinned
26.5.1,ruff checkandruff format --checkclean on both touched files(
rw5-lint.txt).Suggested upstream PR title
blueprints: re-apply when a file referenced by !File changes