Skip to content

fix: return heredoc bodies as real multi-line values when unquoting (#303) - #324

Merged
kkozik-amplify merged 3 commits into
mainfrom
fix/heredoc-flatten-real-newlines
Aug 26, 2026
Merged

fix: return heredoc bodies as real multi-line values when unquoting (#303)#324
kkozik-amplify merged 3 commits into
mainfrom
fix/heredoc-flatten-real-newlines

Conversation

@kkozik-amplify

@kkozik-amplify kkozik-amplify commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

caused by #303

Problem

preserve_heredocs=False strips the <<-EOT markers as documented, but converts every real newline in the body into a literal \n (backslash + n). Reported from production: a heredoc-defined PGP private key came out as a single line and was silently unusable.

I verified the reporter's strongest claim rather than taking it on trust — brute-forcing all sixteen combinations of strip_string_quotes, preserve_heredocs, wrap_objects and explicit_blocks:

combinations yielding v7 semantics: 0

There was genuinely no way to get a plain multi-line string out of 8.x.

Root cause

hcl2/rules/strings.py. The flatten path escapes the body to build a quoted-string source form, and that escaping ran before the strip_string_quotes early return:

heredoc = _strip_closing_marker_line(match.group(2))
heredoc = heredoc.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
if options.strip_string_quotes:
    return heredoc        # ← escaped SOURCE, where the caller asked for a VALUE
return f'"{heredoc}"'

HeredocTrimTemplateRule has the same shape via its sep = "\\n".

This is the same defect #313 fixed for quoted stringsstrip_string_quotes should yield values, not source. That change touched StringRule and left both heredoc rules behind.

Fix

Move the escaping after the early return, in both rules. The quoted form is unchanged.

Options Before After
strip_string_quotes + preserve_heredocs=False 'line1\\nline2' 'line1\nline2'
preserve_heredocs=False alone '"line1\\nline2"' unchanged

What the tests said

The only two failures across the whole suite were the tests asserting the reported behaviour:

self.assertEqual(rule.serialize(opts), "line1\\nline2")

No golden or integration file moved. Both now assert real newlines, and each gained a sibling pinning that the quoted form still escapes — so the value path and the source path cannot drift apart again.

Added TestHeredocFlattenedToValue covering the plain and trimmed forms, the reported multi-line-secret shape, and that embedded quotes and backslashes are escaped in the source form but not the value form.

Docs

The issue notes the migration guide never mentions this, which is how the reporter ended up with no working combination. Fixed both:

  • docs/01_getting_started.md — the option table said only "Keep heredocs in their original form"; it now describes what False actually produces and how to get the value.
  • docs/06_migrating_to_v8.mdpreserve_heredocs=False added to the V7_COMPAT recipe, with a worked example and a note that it needs strip_string_quotes alongside it.

Both documented outputs were executed against the code, not written from memory.

Related

Test plan

  • Full suite: 1511 passing
  • pre-commit run: clean (ruff check, ruff format, mypy)
  • The issue's exact reproduction now yields real newlines: 3 | literal backslash-n: 0

…303)

The flatten path escapes the body to build a quoted-string source form
(`'"a\nb"'`). That escaping ran before the strip_string_quotes early
return, so a caller asking for the *value* got escaped *source* back:
every line break arrived as a literal backslash-n.

Reported from production, where a heredoc-defined PGP private key came
out as a single line and was silently unusable. Before this fix no
combination of SerializationOptions reproduced v7's plain multi-line
string -- confirmed by brute-forcing all sixteen combinations of
strip_string_quotes, preserve_heredocs, wrap_objects and explicit_blocks.

This is the same defect #313 fixed for quoted strings -- strip_string_quotes
should yield values, not source -- which touched StringRule and left both
heredoc rules behind. Moving the escaping after the early return in each
is the whole change; the quoted form is unaffected.

The only two tests that failed were the ones asserting the reported
behaviour (`"line1\\nline2"`). Both now assert real newlines, and each
gains a sibling pinning that the quoted form still escapes, so the two
paths cannot drift again.

Docs: the option table described preserve_heredocs only as "keep
heredocs in their original form", and the migration guide's V7_COMPAT
recipe omitted it entirely -- which is how the reporter ended up without
a working combination. Both now cover it, and the guide's example output
is verified against the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kkozik-amplify
kkozik-amplify requested a review from a team as a code owner August 26, 2026 08:49
Self-review turned up an asymmetry the guide did not mention: with
strip_string_quotes set, a quoted string's `\n` resolves to a newline
while a heredoc body's stays two literal characters. Both are correct --
HCL processes escapes in quoted templates only -- but a reader coming
from the V7_COMPAT recipe has no way to predict it.

Same for line endings: a heredoc in a CRLF file yields a body containing
`\r\n`, since a carriage return inside the body is content rather than
structure.

Example output verified against the code rather than written from memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#323 landed the single-character delimiter fix. Only test_api.py
conflicted -- both branches append a test class -- resolved by keeping
both, main's first.

CHANGELOG.md auto-merged this time thanks to the union driver from #319,
so the usual two-file conflict was one.

Verified the two heredoc changes compose: a single-character delimiter
flattened to a value yields real newlines for the plain, trimmed and
empty forms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kkozik-amplify
kkozik-amplify merged commit 06279a0 into main Aug 26, 2026
7 checks passed
@kkozik-amplify
kkozik-amplify deleted the fix/heredoc-flatten-real-newlines branch August 26, 2026 09:47
kkozik-amplify added a commit that referenced this pull request Aug 26, 2026
#323 and #324 landed after this branch was opened. Added in the same
style as the rest of the section -- PR link, no credit line, since both
were fixed in-house.

CHANGELOG.md conflicted because main carried the ungroomed form of every
entry this branch had already rewritten; kept the groomed text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants