From 44fd89009ef2613e72791064ba50fae88e79c2ce Mon Sep 17 00:00:00 2001 From: Phil Elwell Date: Fri, 6 Nov 2026 21:06:15 +0000 Subject: [PATCH 1/2] otamaker: Reject bad payload names, reproducible builds 1. Demand that payload names are simple filenames, no more than 100 characters long and with no path elements such as slashes. 2. If the SOURCE_DATE_EPOCH environment variable is set, use that as the timestamp on artefact tar achive members so that the whole file has a consistent SHA256 hash between builds if the content does not change. Manifest hashes are already consistent. Signed-off-by: Phil Elwell --- otamaker/otamaker | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/otamaker/otamaker b/otamaker/otamaker index 5865317..523cf9b 100755 --- a/otamaker/otamaker +++ b/otamaker/otamaker @@ -6,7 +6,8 @@ Usage: otamaker [] [] Each payload is read from the file named by its 'source:' key, or by a comment on its 'name:' line (the older form), or failing both by its name. Paths are relative to the current directory, or to -C . 'source:' -is dropped from the copy of the contents in the artefact. +is dropped from the copy of the contents in the artefact. If SOURCE_DATE_EPOCH +is set in the environment, the archive members are given that mtime. -t builds a transitional artefact, which applicators can install either as a manifest (format 2) artefact or, if they predate manifests, as a legacy @@ -99,6 +100,16 @@ PAYLOAD_TYPES = { # individually-compressed payload (e.g. 'boot.sparse.zst' instead of # 'boot.sparse') is almost always a mistake. COMPRESSED_SUFFIXES = ('.zst', '.gz', '.bz2', '.xz', '.lz4', '.tgz', '.tzst') +MAX_NAME_LEN = 100 # The tar header name field, as the applicator limits it + +def is_simple_name(name): + """A payload name identifies the archive member (and perhaps a slot), so + must be a simple name rather than a path: no '/', not '.' or '..', no + control characters, and short enough for a tar header. The file it is + built from ('source:', a comment, or failing both the name) is separate.""" + if not name or len(name) > MAX_NAME_LEN or name in ('.', '..'): + return False + return not any(c == '/' or ord(c) < 0x20 or ord(c) == 0x7f for c in name) def parse_size(text): m = re.match(r'^(\d+)([KkMm]?)$', text) @@ -197,6 +208,9 @@ def build_manifest(contents, payloads_idx, groups, tmpdir, chunk_size, manifest_ if hashlist in names: print(f'* error: hashlist name {hashlist} clashes with a payload') sys.exit(1) + if len(hashlist) > MAX_NAME_LEN: + print(f'* error: hashlist name {hashlist} is too long') + sys.exit(1) digests = chunk_digests(path, chunk_size) with open(os.path.join(tmpdir, hashlist), 'wb') as fh: fh.write(digests) @@ -401,12 +415,22 @@ def main(): groups[-1]['name'] = name_m.group(1).strip() groups[-1]['comment'] = comment + seen_names = set() for g in groups: name = g['name'] if name is None: print("* error: payload has no name") errors += 1 continue + if not is_simple_name(name): + print(f"* error: payload name '{name}' is not a simple name (no '/', '.' or '..')") + errors += 1 + continue + if name in seen_names: + print(f"* error: duplicate payload name '{name}'") + errors += 1 + continue + seen_names.add(name) if g['source'] is not None: target = g['source'] elif g['comment'] is not None: @@ -558,7 +582,16 @@ def main(): tar_cmd = [ 'tar', '--zstd', '-h', '--hard-dereference', '-cvf', outfile, '-C', tmpdir - ] + payloads + ] + # Honour the reproducible-builds convention: with SOURCE_DATE_EPOCH set, + # every member is given that mtime instead of the time it was copied + sde = os.environ.get('SOURCE_DATE_EPOCH') + if sde is not None: + if not re.fullmatch(r'\d+', sde.strip()): + print(f"* error: SOURCE_DATE_EPOCH '{sde}' is not an integer") + sys.exit(1) + tar_cmd.append(f'--mtime=@{sde.strip()}') + tar_cmd += payloads r = subprocess.run(tar_cmd, capture_output=True) if r.returncode != 0: sys.exit(r.returncode) From f2566fd657a4110b14a180d2ce285b2cb3fcc9ef Mon Sep 17 00:00:00 2001 From: Phil Elwell Date: Mon, 9 Nov 2026 16:03:31 +0000 Subject: [PATCH 2/2] otamaker: Always use .manifest.sha256sum for mhashes Prior to this change, both legacy and manifest artefacts were created with hashes in .sha256sum. This would be a clean end point, but is likely to be confusing to users in the short term. Instead, always write manifest hashes to .manifest.sha256sum, even when that is the only hash being written. --- otamaker/README.md | 11 ++++++----- otamaker/otamaker | 17 +++++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/otamaker/README.md b/otamaker/README.md index 07c55ce..746bff4 100644 --- a/otamaker/README.md +++ b/otamaker/README.md @@ -30,9 +30,10 @@ options: -C DIR, --directory DIR the directory payload paths are relative to (default: the current directory) - -w, --writesum also write the sha256sum to .sha256sum (and - for a transitional artefact, the manifest hash to - .manifest.sha256sum) + -w, --writesum also write the manifest hash to + .manifest.sha256sum, and (for a legacy or + transitional artefact) the hash of the whole artefact + to .sha256sum -t, --transitional build a transitional artefact, with a manifest and a legacy contents file for older applicators (the default) @@ -138,9 +139,9 @@ otamaker can build three kinds of artefact, chosen with one of `-t`, `-m` and `- With `-m` or `-t`, otamaker adds `size` and `sha256` fields to each payload in the manifest. Payloads larger than the chunk size (`-c`, default 4M, up to 64M) are instead hashed one chunk at a time, with the per-chunk digests in an extra `.hashes` payload placed before them, so that the applicator can check a large image as it is written. The manifest gets a `format` field, currently always 2; a `format` in the contents file asks for at least that version, and `-f` (`--force`) allows an unsupported one, e.g. to test applicators. Don't put `size`, `sha256` or `chunk_size` in the contents file yourself. -`-w` (`--writesum`) writes the published hash to `.sha256sum`. For a transitional artefact it writes the whole-archive hash there, and the manifest hash to `.manifest.sha256sum`. +`-w` (`--writesum`) writes the whole-archive hash to `.sha256sum` in legacy and transitional modes, and the manifest hash to `.manifest.sha256sum` in manifest and transitional modes. -Note that the manifest hash doesn't change between builds from the same contents and payloads, unlike the whole-archive hash, which depends on timestamps. +Note that the manifest hash doesn't change between builds from the same contents and payloads, unlike the whole-archive hash, which depends on timestamps. However, if SOURCE_DATE_EPOCH is set in the environment, the archive members are given that mtime, resulting in consistent whole-archive hashes when run by the same user provided the payloads don't change. **Signing** diff --git a/otamaker/otamaker b/otamaker/otamaker index 523cf9b..f6c8a60 100755 --- a/otamaker/otamaker +++ b/otamaker/otamaker @@ -270,8 +270,9 @@ def main(): '(default: the current directory)', metavar='DIR') parser.add_argument('-w', '--writesum', - help='also write the sha256sum to .sha256sum (and for a transitional ' - 'artefact, the manifest hash to .manifest.sha256sum)', + help='also write the manifest hash to .manifest.sha256sum, and (for ' + 'a legacy or transitional artefact) the hash of the whole artefact to ' + '.sha256sum', action='store_true') kind = parser.add_mutually_exclusive_group() kind.add_argument('-t', '--transitional', @@ -602,13 +603,17 @@ def main(): sum = file_sha256(contents_path if args.manifest else outfile) legacy_sum = file_sha256(outfile) if args.transitional else None if args.writesum: - if legacy_sum: + # The manifest hash always goes to .manifest.sha256sum, + # whichever kind of artefact it is in, and the hash of the whole + # artefact to .sha256sum. + if args.legacy: with open(f'{outfile}.sha256sum', 'w') as sf: - print(legacy_sum, file=sf) - with open(f'{outfile}.manifest.sha256sum', 'w') as sf: print(sum, file=sf) else: - with open(f'{outfile}.sha256sum', 'w') as sf: + if legacy_sum: + with open(f'{outfile}.sha256sum', 'w') as sf: + print(legacy_sum, file=sf) + with open(f'{outfile}.manifest.sha256sum', 'w') as sf: print(sum, file=sf) print('Contents:')