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 5865317..f6c8a60 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) @@ -256,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', @@ -401,12 +416,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 +583,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) @@ -569,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:')