package-notes/generate-package-notes.py

223 lines
8.9 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: CC0-1.0
"""
Generates a linker script to insert a .note.package section with a
JSON payload. The contents are derived from the specified options and the
os-release file. Use the output with -Wl,-dT,/path/to/output in $LDFLAGS.
$ ./generate-package-notes.py --package-type rpm --package-name systemd --package-version 248~rc2-1.fc34 --package-architecture x86_64 --cpe 'cpe:/o:fedoraproject:fedora:33'
SECTIONS
{
.note.package (READONLY) : ALIGN(4) {
BYTE(0x04) BYTE(0x00) BYTE(0x00) BYTE(0x00) /* Length of Owner including NUL */
BYTE(0x7c) BYTE(0x00) BYTE(0x00) BYTE(0x00) /* Length of Value including NUL */
BYTE(0x7e) BYTE(0x1a) BYTE(0xfe) BYTE(0xca) /* Note ID */
BYTE(0x46) BYTE(0x44) BYTE(0x4f) BYTE(0x00) /* Owner: 'FDO\x00' */
BYTE(0x7b) BYTE(0x22) BYTE(0x74) BYTE(0x79) /* Value: '{"type":"rpm","name":"systemd","version":"248~rc2-1.fc34","architecture":"x86_64","osCpe":"cpe:/o:fedoraproject:fedora:33"}\x00' */
BYTE(0x70) BYTE(0x65) BYTE(0x22) BYTE(0x3a)
BYTE(0x22) BYTE(0x72) BYTE(0x70) BYTE(0x6d)
BYTE(0x22) BYTE(0x2c) BYTE(0x22) BYTE(0x6e)
BYTE(0x61) BYTE(0x6d) BYTE(0x65) BYTE(0x22)
BYTE(0x3a) BYTE(0x22) BYTE(0x73) BYTE(0x79)
BYTE(0x73) BYTE(0x74) BYTE(0x65) BYTE(0x6d)
BYTE(0x64) BYTE(0x22) BYTE(0x2c) BYTE(0x22)
BYTE(0x76) BYTE(0x65) BYTE(0x72) BYTE(0x73)
BYTE(0x69) BYTE(0x6f) BYTE(0x6e) BYTE(0x22)
BYTE(0x3a) BYTE(0x22) BYTE(0x32) BYTE(0x34)
BYTE(0x38) BYTE(0x7e) BYTE(0x72) BYTE(0x63)
BYTE(0x32) BYTE(0x2d) BYTE(0x31) BYTE(0x2e)
BYTE(0x66) BYTE(0x63) BYTE(0x33) BYTE(0x34)
BYTE(0x22) BYTE(0x2c) BYTE(0x22) BYTE(0x61)
BYTE(0x72) BYTE(0x63) BYTE(0x68) BYTE(0x69)
BYTE(0x74) BYTE(0x65) BYTE(0x63) BYTE(0x74)
BYTE(0x75) BYTE(0x72) BYTE(0x65) BYTE(0x22)
BYTE(0x3a) BYTE(0x22) BYTE(0x78) BYTE(0x38)
BYTE(0x36) BYTE(0x5f) BYTE(0x36) BYTE(0x34)
BYTE(0x22) BYTE(0x2c) BYTE(0x22) BYTE(0x6f)
BYTE(0x73) BYTE(0x43) BYTE(0x70) BYTE(0x65)
BYTE(0x22) BYTE(0x3a) BYTE(0x22) BYTE(0x63)
BYTE(0x70) BYTE(0x65) BYTE(0x3a) BYTE(0x2f)
BYTE(0x6f) BYTE(0x3a) BYTE(0x66) BYTE(0x65)
BYTE(0x64) BYTE(0x6f) BYTE(0x72) BYTE(0x61)
BYTE(0x70) BYTE(0x72) BYTE(0x6f) BYTE(0x6a)
BYTE(0x65) BYTE(0x63) BYTE(0x74) BYTE(0x3a)
BYTE(0x66) BYTE(0x65) BYTE(0x64) BYTE(0x6f)
BYTE(0x72) BYTE(0x61) BYTE(0x3a) BYTE(0x33)
BYTE(0x33) BYTE(0x22) BYTE(0x7d) BYTE(0x00)
}
}
INSERT AFTER .note.gnu.build-id;
/* HINT: add -Wl,-dT,/path/to/this/file to $LDFLAGS */
See https://systemd.io/COREDUMP_PACKAGE_METADATA/ for details.
"""
__version__ = '0.8'
import argparse
import itertools
import os
import re
from pathlib import Path
import simplejson as json
DOC_PARAGRAPHS = ['\n'.join(group)
for (key, group) in itertools.groupby(__doc__.splitlines(), bool)
if key]
def read_os_release(field, root=Path('/')):
try:
f = open(root / 'etc/os-release')
except FileNotFoundError:
f = open(root / 'usr/lib/os-release')
prefix = '{}='.format(field)
for line in f:
if line.startswith(prefix):
break
else:
return None
value = line.rstrip()
value = value[value.startswith(prefix) and len(prefix):]
if value[0] in '"\'' and value[0] == value[-1]:
value = value[1:-1]
return value
def str_to_bool(v):
if isinstance(v, bool):
return v
if v.lower() in {'yes', 'true', '1'}:
return True
if v.lower() in {'no', 'false', '0'}:
return False
raise argparse.ArgumentTypeError('"yes"/"true"/"1"/"no"/"false"/"0" expected')
def parse_args():
p = argparse.ArgumentParser(description=DOC_PARAGRAPHS[0],
epilog=DOC_PARAGRAPHS[-1],
allow_abbrev=False)
p.add_argument('--package-type', metavar='TYPE',
default='package',
help='Specify the package type, e.g. "rpm" or "deb"')
p.add_argument('--package-name', metavar='NAME',
help='The name of the package (e.g. "foo" or "libbar")')
p.add_argument('--package-version', metavar='VERSION',
help='The full version of the package (e.g. 1.5-1.fc35.s390x)')
p.add_argument('--package-architecture', metavar='ARCH',
help='The code architecture of the binaries (e.g. arm64 or s390x)')
p.add_argument('--cpe',
help='NIST CPE identifier of the vendor operating system, or \'auto\' to parse from system-release-cpe or os-release')
p.add_argument('--rpm', metavar='NEVRA',
help='Extract type,name,version,architecture from a full rpm name')
p.add_argument('--debug-info-url', metavar='URL',
help='URL of the debuginfod server where sources can be queried')
p.add_argument('--readonly', metavar='BOOL',
type=str_to_bool, default=True,
help='Make the notes section read-only (requires binutils 2.38)')
p.add_argument('--root', metavar='PATH', type=Path, default="/",
help='When a file (eg: /usr/lib/os-release) is parsed, open it relatively from this hierarchy')
p.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
opts = p.parse_args()
return opts
def encode_bytes(arr):
return ' '.join('BYTE(0x{:02x})'.format(n) for n in arr)
def encode_bytes_lines(arr, prefix='', label='string'):
assert len(arr) % 4 == 0
s = bytes(arr).decode()
yield prefix + encode_bytes(arr[:4]) + ' /* {}: {!r} */'.format(label, s)
for offset in range(4, len(arr), 4):
yield prefix + encode_bytes(arr[offset:offset+4])
def encode_length(s, prefix='', label='string'):
n = (len(s) + 1) * 4 // 4
return f'{prefix}LONG(0x{n:04x}) /* Length of {label} including NUL */'
def encode_note_id(id, prefix=''):
return f'{prefix}LONG(0x{id:04x}) /* Note ID */'
def pad_string(s):
return [0] * ((len(s) + 4) // 4 * 4 - len(s))
def encode_string(s, prefix='', label='string'):
arr = list(s.encode()) + pad_string(s)
yield from encode_bytes_lines(arr, prefix=prefix, label=label)
def encode_note(note_name, note_id, owner, value, readonly=True, prefix=''):
l1 = encode_length(owner, prefix=prefix + ' ', label='Owner')
l2 = encode_length(value, prefix=prefix + ' ', label='Value')
l3 = encode_note_id(note_id, prefix=prefix + ' ')
l4 = encode_string(owner, prefix=prefix + ' ', label='Owner')
l5 = encode_string(value, prefix=prefix + ' ', label='Value')
readonly = '(READONLY) ' if readonly else ''
return [prefix + '.note.{} {}: ALIGN(4) {{'.format(note_name, readonly),
l1, l2, l3, *l4, *l5,
prefix + '}']
NOTE_ID= 0xcafe1a7e
def json_serialize(s):
# Avoid taking space in the ELF header if there's no value to store
return json.dumps({k: v for k, v in s.items() if v is not None},
ensure_ascii=False,
separators=(',', ':'))
def gather_data(opts):
if opts.cpe == 'auto':
try:
with open(Path(opts.root, 'usr/lib/system-release-cpe'), 'r') as f:
opts.cpe = f.read()
except FileNotFoundError:
opts.cpe = read_os_release('CPE_NAME', root=opts.root)
if opts.cpe is None or opts.cpe == "":
raise ValueError(f"Could not read {opts.root}usr/lib/system-release-cpe or CPE_NAME from {opts.root}usr/lib/os-release")
if opts.rpm:
split = re.match(r'(.*?)-([0-9].*)\.(.*)', opts.rpm)
if not split:
raise ValueError('{!r} does not seem to be a valid package name'.format(opts.rpm))
opts.package_type = 'rpm'
opts.package_name = split.group(1)
opts.package_version = split.group(2)
opts.package_architecture = split.group(3)
data = {
'type': opts.package_type,
'name': opts.package_name,
'version': opts.package_version,
'architecture': opts.package_architecture,
}
if opts.cpe:
data['osCpe'] = opts.cpe
else:
data['os'] = read_os_release('ID', root=opts.root)
data['osVersion'] = read_os_release('VERSION_ID', root=opts.root)
if opts.debug_info_url:
data['debugInfoUrl'] = opts.debug_info_url
return data
def generate_section(data, readonly=True):
json = json_serialize(data)
section = encode_note('package', NOTE_ID, 'FDO', json, readonly=readonly, prefix=' ')
return ['SECTIONS', '{',
*section,
'}',
'INSERT AFTER .note.gnu.build-id;',
'/* HINT: add -Wl,-dT,/path/to/this/file to $LDFLAGS */']
if __name__ == '__main__':
opts = parse_args()
data = gather_data(opts)
lines = generate_section(data, readonly=opts.readonly)
print('\n'.join(lines))