Skip to content

scaffold-project.py writes --elected-by, --reference and --name between literal double quotes with no escaping and asks none of them whether a file can hold it, so a line break leaves a project.yaml its own validators refuse, a backslash is recorded as another character (CORP\nick as CORP, a line feed, ick), and a byte that is not UTF-8 dies in write_lf after the legs are pushed #282

Closed

Description

@brettheap

(lane openRepoShape-2, 2026-10-11T23:46:28Z)

Noted by the writer of PR #270 (which closes the adopt half) and reproduced by this lane on main c3f4987 (git 2.43.0, Python 3.12.3), with local bare remotes only and no network. Pre-existing. Not claimed.

What is wrong. scaffold-project.py takes --elected-by, --reference and --name as typed and puts each into templates/assembly-root/project.yaml between literal double quotes (name: "{{PROJECT_NAME}}", reference: "{{REFERENCE}}", elected_by: "{{ELECTED_BY}}"). Nothing escapes the value, and nothing asks whether a file can hold it. --dry-run took each input I tried (exit 0), because it returns before anything is written. The elector's fallback, GIT_AUTHOR_NAME or git config user.name, takes the same road. What the project then does with the file:

value scaffold what lands what then happens
a line break: LF, CR LF, U+0085 or U+2028 in --elected-by, --reference or --name exit 0, all three repositories pushed the quoted scalar runs over two lines (elected_by: "Ada / Lovelace") validate-manifest.py, validate-repository-naming.py and scripts/bootstrap.py exit 2 yaml-unparsable (bootstrap's remediation is about submodules); shape-doctor.py --root calls the new root "not a shape root" and its NEXT is adopt-project.py plan --source <the new root>
a backslash: --elected-by 'CORP\nick' (also through GIT_AUTHOR_NAME), --reference 'C:\docs\notes\new.md' exit 0, pushed the backslash is written as typed the project's own reader decodes \n and \t as escapes: CORP\nick reads back as CORP, a line feed, ick, and the Windows path as C:\docs, a line feed, otes, a line feed, ew.md. validate-manifest.py says manifest ok; README.md says CORP\nick; PyYAML refuses the path (found unknown escape character 'd')
a double quote: Joe "JJ" Smith exit 0, pushed elected_by: "Joe "JJ" Smith" the project's reader takes the first and last quote and returns the text; PyYAML raises ParserError, so any other tool reading project.yaml refuses it
a byte that is not UTF-8 ($'Jos\xe9', a lone surrogate once Python decodes argv) in --elected-by or --reference exit 1, a traceback: UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' in write_lf both legs pushed, the assembly root created and empty the corrected re-run is refused scaffold-remote-exists on the empty root; with --reuse-empty-repo it is refused again on Atlas-spec ("the two legs must not exist yet ... Delete them")
the same byte in --name exit 1, the same traceback all three repositories created and empty, nothing pushed the same two refusals
a control character: U+0001, ESC, DEL exit 0, pushed written raw into project.yaml PyYAML raises ReaderError: special characters are not allowed; the project's reader and validators pass it
a bidi control: U+202E, U+2066, U+2069 exit 0, pushed raw in project.yaml:46 and in README.md:9 nothing objects; it is the question #270's review raises for adopt (unwritable_reason accepts Cf)

Not a defect, and so not in this issue: : , # and ' inside --elected-by (the quotes protect them, both readers read them back exactly), and a Persian name joined with U+200C. A NUL cannot reach the tool at all: argv refuses it (ValueError: embedded null byte). Git accepts every value that gets as far as a commit; none of these reaches a git command line except --name, in a commit subject.

SHAPE=<openRepoShape checkout>
export GIT_AUTHOR_NAME=T GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=T GIT_COMMITTER_EMAIL=t@example.invalid LC_ALL=C.UTF-8
scaffold() {   # scaffold LABEL FLAG VALUE: one scaffold into local bare repositories, then the project's own gate
  TMP=$(mktemp -d); label=$1; flag=$2; value=$3
  who=(--elected-by 'T H'); [ "$flag" = --elected-by ] && who=()
  python3 "$SHAPE/scaffold-project.py" --org testorg --project Atlas --elected-on 2026-09-02 "${who[@]}" \
      "$flag" "$value" --local-remote-dir "$TMP/remotes" --work-dir "$TMP/work" > "$TMP/out" 2> "$TMP/err"
  rc=$?
  echo "== $label: scaffold exit $rc, commits pushed: $(for r in Atlas Atlas-spec Atlas-code; do printf '%s=%s ' $r "$(git -C "$TMP/remotes/$r.git" rev-list --count --all)"; done)"
  [ $rc = 0 ] || { tail -1 "$TMP/err" | cut -c1-110; return; }
  cd "$TMP/work/Atlas" || return
  python3 scripts/validate-manifest.py > "$TMP/gate" 2>&1; echo "   validate-manifest.py exit $?: $(head -1 "$TMP/gate" | sed "s#$TMP#<TMP>#g" | cut -c1-110)"
  python3 - "$flag" <<'PY'
import sys; sys.path.insert(0, "scripts")
from repo_shape import parse_yaml
key = sys.argv[1].lstrip("-").replace("-", "_")
text = open("project.yaml", encoding="utf-8").read()
line = [l for l in text.splitlines() if l.startswith(key + ":")]
try:
    value = parse_yaml(text)[key]
    print(f"   project.yaml holds {line!r}\n   the project's reader gives {value!r}, with {value.count(chr(10))} line feed(s)")
except Exception as exc:
    print(f"   project.yaml holds {line!r}\n   the project's reader raises {type(exc).__name__}: {exc}")
PY
  python3 "$SHAPE/shape-doctor.py" --root . 2>&1 | grep -a -E "^kind|^NOT A SHAPE ROOT|^ *NEXT +python3 .*adopt" | sed "s#$TMP#<TMP>#g;s#$SHAPE#<SHAPE>#g" | cut -c1-130 | sed 's/^/   doctor: /'
  cd - > /dev/null
}
scaffold 'a line break in --elected-by'        --elected-by $'Ada\nLovelace'
scaffold 'DOMAIN\user in --elected-by'         --elected-by 'CORP\nick'
scaffold 'a Windows path in --reference'       --reference 'C:\docs\notes\new.md'
scaffold 'a byte that is not UTF-8 in --reference' --reference $'ref-\xe9'
scaffold 'a byte that is not UTF-8 in --name'  --name $'Jos\xe9'

Observed on main c3f4987 (<TMP> and <SHAPE> for paths):

== a line break in --elected-by: scaffold exit 0, commits pushed: Atlas=1 Atlas-spec=1 Atlas-code=1
   validate-manifest.py exit 2: REFUSED yaml-unparsable: <TMP>/work/Atlas/project.yaml: line 47: expected `key: value`, got 'Lovelace"'
   project.yaml holds ['elected_by: "Ada']
   the project's reader raises YamlError: line 47: expected `key: value`, got 'Lovelace"'
   doctor: kind        not a shape root
   doctor:             NEXT          python3 <SHAPE>/adopt-project.py plan --source <TMP>/work/Atlas --project Atlas
   doctor: NOT A SHAPE ROOT   (exit 2)
== DOMAIN\user in --elected-by: scaffold exit 0, commits pushed: Atlas=1 Atlas-spec=1 Atlas-code=1
   validate-manifest.py exit 0: manifest ok: Atlas (atlas), 3 legs
   project.yaml holds ['elected_by: "CORP\\nick"']
   the project's reader gives 'CORP\nick', with 1 line feed(s)
== a Windows path in --reference: scaffold exit 0, commits pushed: Atlas=1 Atlas-spec=1 Atlas-code=1
   validate-manifest.py exit 0: manifest ok: Atlas (atlas), 3 legs
   project.yaml holds ['reference: "C:\\docs\\notes\\new.md"']
   the project's reader gives 'C:\\docs\notes\new.md', with 2 line feed(s)
== a byte that is not UTF-8 in --reference: scaffold exit 1, commits pushed: Atlas=0 Atlas-spec=1 Atlas-code=1
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' in position 436: surrogates not allowed
== a byte that is not UTF-8 in --name: scaffold exit 1, commits pushed: Atlas=0 Atlas-spec=0 Atlas-code=0
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' in position 31: surrogates not allowed

Against GitHub the same phases run (_create_remotes makes all three repositories before _seed_legs renders anything), so the last two rows leave real repositories behind (read from code; not run, since a scaffold that is not a dry run must not reach GitHub from a review).

What should happen. Two changes, one per half of the gap.

  1. The three values are asked whether a file can hold them, before anything exists and under --dry-run too: a byte that is not UTF-8, a line break and a control character are refused by name, in _identity beside election_date. PR adopt-project.py: a value execute writes into a file is asked whether a file can hold it, in plan, in check and in execute before any leg exists, so a byte that is not UTF-8 in --elected-by, --reference or a local --source directory no longer pushes both legs before write_lf raises (#255) #270 adds the question as shape_materialize.unwritable_reason (aed9190) for adopt-project.py; the scaffold should ask the same one, so the two tools refuse the same values in the same words. It does not cover the next point: at aed9190 it returns None for ", \ and a bidi control.
  2. The three quoted lines are filled with the value escaped for a double-quoted scalar, so a backslash and a quote read back exactly: repo_shape.double_quoted is the one writer here that does it, and it is the one adopt's plan already uses. README.md and the two AGENTS.md lines are Markdown and keep the raw text. The templates already quote these three lines, which is the convention templates/assembly-root/project.yaml and the two leg pins write eight text placeholders bare (id, elected_on, visibility, tracking_branch, the two leg paths and submodule_paths), so a checked value YAML reads as another type (007, 2026, true, null) is misread by the project or refused by the tool; quoting them reads every one back exactly and lets numeric branch names adopt #259 proposes for the other placeholders; what they lack is the escape.

Tests, each both through --dry-run and through a real scaffold into --local-remote-dir followed by the project's validate-manifest.py: a value holding \n, \t, " and C:\docs\new.md reads back through parse_yaml as the typed text; a line break, a lone surrogate and a control character are refused as one named refusal with no repository created; the same three through the GIT_AUTHOR_NAME fallback.

Where (main c3f4987):

  • scaffold-project.py:487-510 (_elector: the value as typed, refused only when empty; the GIT_AUTHOR_NAME and git config user.name fallback at :495-503); :525 (display = args.name or project) and :528 (reference = args.reference or default_reference(...)) in _identity, which asks none of them anything; :1075, :1079, :1080 put them into the substitution table as text.
  • scaffold-project.py:1104 (--dry-run returns here), :1125 _create_remotes, :1128 _seed_legs (renders templates/spec-root and code-root, where PROJECT_NAME is in AGENTS.md:1), :1133 _build_assembly_root and :888 materialize_assembly_root (where ELECTED_BY and REFERENCE are rendered): the order that leaves the repositories behind.
  • templates/assembly-root/project.yaml:32, :42, :46 (the three quoted lines) and templates/assembly-root/README.md:1, :9, :10.
  • scripts/shape_materialize.py:1045 (render, a plain str.replace) and :70-84 (write_lf, strict UTF-8).
  • scripts/repo_shape.py:269 (DOUBLE_QUOTED_ESCAPES), :337 (_unescape, which decodes \n and \t in the values above), :365 (_scalar, which tests only the first and last character for a quote) and :284 (double_quoted, the escaping writer).

Related.

Not claimed.

🤖 Generated with Claude Code

Activity

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-5, session e62c2fd7-176d-429d-89fb-0653dd08ad5f@Eagle, 2026-10-12T11:02:53Z, for #282

    Logged in lanes/log/openRepoShape-5.md at f26c632829a0c1e9f7ad5085971bf26ecf2b0741.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#282:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#282"`, kept where the row or its body names `#282`:
    270	adopt-project.py: a value `execute` writes into a file is asked whether a file can hold it, in `plan`, in `check` and in `execute` before any leg exists, so a byte that is not UTF-8 in `--elected-by`, `--reference` or a local `--source` directory no longer pushes both legs before `write_lf` raises (#255)	fix/adopt-template-values-utf8	OPEN
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `282` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions