emtrace 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ /dist
2
+ /emtrace/_build_info.py
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright [yyyy] [name of copyright owner]
189
+ Copyright 2026 Jonas Beyer
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: emtrace
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: A tool to parse and format emtrace data.
5
5
  Author-email: Jonas Beyer <reyeb.sanoj@googlemail.com>
6
6
  License: Apache License
@@ -191,7 +191,7 @@ License: Apache License
191
191
  same "printed page" as the copyright notice for easier
192
192
  identification within third-party archives.
193
193
 
194
- Copyright [yyyy] [name of copyright owner]
194
+ Copyright 2026 Jonas Beyer
195
195
 
196
196
  Licensed under the Apache License, Version 2.0 (the "License");
197
197
  you may not use this file except in compliance with the License.
@@ -206,7 +206,7 @@ License: Apache License
206
206
  limitations under the License.
207
207
  License-File: LICENSE
208
208
  Keywords: embedded,logging,tracing
209
- Requires-Dist: pyelftools>=0.31
209
+ Requires-Dist: lief>=0.17
210
210
  Provides-Extra: test
211
211
  Requires-Dist: pytest-xdist>=3.6.1; extra == 'test'
212
212
  Requires-Dist: pytest>=8.2.2; extra == 'test'
@@ -0,0 +1,64 @@
1
+ """emtrace: parse and format emtrace trace data (pure-python library).
2
+
3
+ The command-line interface lives in :mod:`emtrace.cli` and is intentionally
4
+ *not* imported here, so ``import emtrace`` stays lightweight and free of
5
+ CLI side effects.
6
+ """
7
+
8
+ __version__ = "0.2.0"
9
+
10
+ from .edit import nonload_binary, strip_binary
11
+ from .engine import (
12
+ FormatInfoResult,
13
+ RunReport,
14
+ TraceInfoFormat,
15
+ emtrace,
16
+ load_format_info,
17
+ )
18
+ from .errors import Issue, Report
19
+ from .exemod import (
20
+ ExeError,
21
+ ExeImage,
22
+ NonloadReport,
23
+ NotSupportedError,
24
+ SectionNotFoundError,
25
+ StripReport,
26
+ )
27
+ from .inspector import (
28
+ ArchiveDetails,
29
+ BinaryDetails,
30
+ CheckReport,
31
+ ExportReport,
32
+ InspectReport,
33
+ RawDetails,
34
+ check_section,
35
+ export_section,
36
+ inspect_path,
37
+ )
38
+
39
+ __all__ = [
40
+ "ArchiveDetails",
41
+ "BinaryDetails",
42
+ "CheckReport",
43
+ "ExeError",
44
+ "ExeImage",
45
+ "ExportReport",
46
+ "FormatInfoResult",
47
+ "InspectReport",
48
+ "Issue",
49
+ "NonloadReport",
50
+ "NotSupportedError",
51
+ "RawDetails",
52
+ "Report",
53
+ "RunReport",
54
+ "SectionNotFoundError",
55
+ "StripReport",
56
+ "TraceInfoFormat",
57
+ "check_section",
58
+ "emtrace",
59
+ "export_section",
60
+ "inspect_path",
61
+ "load_format_info",
62
+ "nonload_binary",
63
+ "strip_binary",
64
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli.main import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,4 @@
1
+ """Generated by hatch_build.py at build time; do not edit or commit."""
2
+
3
+ COMMIT: str | None = 'f7061e85ac9fe5746afc484ff48fdecd8dd8ac0c'
4
+ DIRTY = False
@@ -0,0 +1,74 @@
1
+ """Sole adapter for the optional ``lief`` dependency.
2
+
3
+ ``lief`` is a default install dependency of the ``emtrace`` package, but the
4
+ library also works without it: anything that does not need to parse an
5
+ executable (raw ``binary`` and ``json`` inputs, record-level helpers) keeps
6
+ working, while executable-based operations report a clear "lief is not
7
+ installed" issue instead of failing at import time.
8
+
9
+ This module is the only place that touches ``lief`` availability:
10
+
11
+ - ``HAS_LIEF`` tells callers whether the dependency is present.
12
+ - ``parse_binary()`` / ``find_section_bytes()`` are defined exactly once
13
+ (typing-only imports under ``TYPE_CHECKING`` keep type checkers happy
14
+ without requiring the package at runtime).
15
+ - :func:`lief_missing_error` builds the standard issue message so every
16
+ command refuses identically.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ try:
26
+ import lief as _lief
27
+ except ImportError:
28
+ _lief = None # type: ignore[assignment]
29
+
30
+ #: Whether the optional ``lief`` dependency is installed.
31
+ HAS_LIEF: bool = _lief is not None
32
+
33
+
34
+ def lief_missing_error(operation: str) -> str:
35
+ """The standard message for refusing *operation* without lief installed."""
36
+ return (
37
+ f"{operation} requires the 'lief' package, which is not installed "
38
+ f"(install the default 'emtrace' dependencies to enable it)"
39
+ )
40
+
41
+
42
+ def parse_binary(kind: str, path: Path) -> Any | None:
43
+ """Parse *path* as a binary of *kind* (Tier 1: ``None`` when unavailable).
44
+
45
+ *kind* is one of ``"exe"``, ``"elf"``, ``"pe"``, ``"macho"`` or ``"oat"``.
46
+ Returns ``None`` when lief is not installed, when the file is not of the
47
+ requested kind, or when parsing fails.
48
+ """
49
+ if _lief is None:
50
+ return None
51
+ if kind == "exe":
52
+ return _lief.parse(path)
53
+ if kind == "elf":
54
+ return _lief.ELF.parse(path)
55
+ if kind == "pe":
56
+ return _lief.PE.parse(path)
57
+ if kind == "macho":
58
+ return _lief.parse(path) if _lief.is_macho(path) else None
59
+ if kind == "oat":
60
+ return _lief.OAT.parse(path)
61
+ raise ValueError(f"unknown binary kind: {kind!r}")
62
+
63
+
64
+ def find_section_bytes(
65
+ trace_info_file: Path, section_name: str, parse: Callable[[Path], Any | None]
66
+ ) -> bytes | None:
67
+ """Extract section bytes via *parse* (Tier 1: ``None`` when absent)."""
68
+ exe = parse(trace_info_file)
69
+ if exe is None:
70
+ return None
71
+ section = exe.get_section(section_name)
72
+ if section is None:
73
+ return None
74
+ return bytes(section.content)
@@ -0,0 +1,64 @@
1
+ import sys
2
+ from collections.abc import Callable
3
+ from typing import Any
4
+
5
+
6
+ def cobs_get_frame(
7
+ istream: Callable[[int], bytes], passthrough: Callable[[bytes], Any] | None = None
8
+ ) -> bytes | None:
9
+ """
10
+ Decodes and returns the next COBS frame in the given input stream.
11
+ The stream is queried until a full COBS frame was received or the stream is empty.
12
+ Optionally provide a passthrough function to decode according to COBS "passthrough" rules.
13
+ In that case frames have to start with a null-byte as well as ending in one,
14
+ and any bytes before the first null-byte, are "passed through" to the provided callback.
15
+ Since a COBS frame cannot consist of just a single null-byte,
16
+ a pair of null-bytes (which would otherwise mean starting and immediately ending a frame)
17
+ is interpreted as an escaped null-byte in the passthrough stream.
18
+ """
19
+
20
+ buffer = bytearray()
21
+ data = istream(1)
22
+
23
+ if passthrough is not None:
24
+ while len(data) != 0:
25
+ if data[0] == 0:
26
+ data = istream(1)
27
+ if len(data) == 0:
28
+ return None
29
+ # two successive null-bytes in input are interpreted as an escaped null-byte
30
+ # in the passthrough stream
31
+ if data[0] != 0:
32
+ break
33
+ passthrough(data)
34
+ data = istream(1)
35
+
36
+ if len(data) == 0:
37
+ return None
38
+ if data[0] == 0:
39
+ print("COBS decoding error: zero byte at start of frame", file=sys.stderr)
40
+ return bytes(buffer)
41
+
42
+ while True:
43
+ block_prefix = data[0]
44
+
45
+ block_size = 254 if block_prefix == 0xFF else block_prefix - 1
46
+
47
+ data = istream(block_size)
48
+ if 0 in data:
49
+ print(
50
+ f"COBS decoding error: zero byte in data block: {data}", file=sys.stderr
51
+ )
52
+ buffer.extend(data)
53
+ if len(data) < block_size:
54
+ if len(buffer) == 0:
55
+ return None
56
+ return bytes(buffer)
57
+ assert len(data) == block_size
58
+
59
+ data = istream(1)
60
+ if len(data) == 0 or data[0] == 0:
61
+ return bytes(buffer)
62
+ if block_prefix != 0xFF:
63
+ buffer.append(0)
64
+ block_prefix = -1
@@ -0,0 +1,103 @@
1
+ """Post-link editing operations for emtrace sections (library core).
2
+
3
+ ``strip`` removes the section completely (ELF, PE, and Mach-O); ``nonload``
4
+ makes the section non-loadable on ELF, the same effect as an ``(INFO)``
5
+ region in a linker script (clear SHF_ALLOC and sh_addr, keep the section
6
+ bytes in the file so the traces remain parseable).
7
+
8
+ Both operations rebuild the binary with LIEF; the guarantees are behavioral
9
+ (the section entry is gone, its symbols are deleted, its content no longer
10
+ parses, and the binary still runs) rather than byte-exact.
11
+
12
+ All entry points return :class:`StripReport` / :class:`NonloadReport` with
13
+ ``errors`` set on failure — this module never prints. File writing lives in
14
+ :mod:`emtrace.cli.edit_cmds`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from .exemod import (
22
+ ExeError,
23
+ ExeImage,
24
+ NonloadReport,
25
+ NotSupportedError,
26
+ SectionNotFoundError,
27
+ StripReport,
28
+ )
29
+
30
+ __all__ = ["nonload_binary", "strip_binary"]
31
+
32
+
33
+ def _open_image(
34
+ source: Path, command: str, experimental_be: bool
35
+ ) -> tuple[ExeImage | None, str | None]:
36
+ """Open *source* for *command*; return ``(image, None)`` or ``(None, error)``."""
37
+ try:
38
+ return ExeImage.open(source, experimental_be=experimental_be), None
39
+ except (ExeError, NotSupportedError, SectionNotFoundError) as e:
40
+ return None, f"{command}: {e}"
41
+
42
+
43
+ def strip_binary(
44
+ path: str | Path,
45
+ section_name: str = ".emtrace",
46
+ *,
47
+ experimental_be: bool = False,
48
+ ) -> StripReport:
49
+ """Remove the section *section_name* from the binary at *path* (Tier 2).
50
+
51
+ On success, ``report.data`` holds the rebuilt binary bytes.
52
+ """
53
+ source = Path(path).resolve()
54
+ report = StripReport(section=section_name)
55
+ if not source.is_file():
56
+ report.fail(f"{source} does not exist.")
57
+ return report
58
+
59
+ image, open_error = _open_image(source, "strip", experimental_be)
60
+ if image is None:
61
+ report.fail(open_error or f"strip: cannot open {source}.")
62
+ return report
63
+
64
+ try:
65
+ inner = image.strip_section(section_name)
66
+ except (SectionNotFoundError, NotSupportedError, ExeError) as e:
67
+ report.fail(f"strip: {e}")
68
+ return report
69
+
70
+ inner.data = image.bytes()
71
+ return inner
72
+
73
+
74
+ def nonload_binary(
75
+ path: str | Path,
76
+ section_name: str = ".emtrace",
77
+ *,
78
+ experimental_be: bool = False,
79
+ ) -> NonloadReport:
80
+ """Make *section_name* non-loadable in the binary at *path* (Tier 2).
81
+
82
+ On success with changes, ``report.data`` holds the rebuilt binary bytes.
83
+ """
84
+ source = Path(path).resolve()
85
+ report = NonloadReport(section=section_name)
86
+ if not source.is_file():
87
+ report.fail(f"{source} does not exist.")
88
+ return report
89
+
90
+ image, open_error = _open_image(source, "nonload", experimental_be)
91
+ if image is None:
92
+ report.fail(open_error or f"nonload: cannot open {source}.")
93
+ return report
94
+
95
+ try:
96
+ inner = image.make_nonload(section_name)
97
+ except (SectionNotFoundError, NotSupportedError, ExeError) as e:
98
+ report.fail(f"nonload: {e}")
99
+ return report
100
+
101
+ if inner.changed:
102
+ inner.data = image.bytes()
103
+ return inner
@@ -0,0 +1,151 @@
1
+ """ELF backend for :mod:`emtrace.exemod` (LIEF-based).
2
+
3
+ Refusal rules (ported from the previous hand-rolled surgery):
4
+ - unlinked object files (ET_REL) are refused; only ET_EXEC/ET_DYN are edited.
5
+ - only data sections (PROGBITS/NOBITS) can be removed: symbol tables, string
6
+ tables, relocations, and other metadata sections are refused, since their
7
+ linkages cannot be expressed safely here.
8
+ - removal is refused when a dynamic symbol references the section (dynamic
9
+ hash tables cannot be rebuilt here) or when a relocation targets the
10
+ section's address range or a deleted symbol.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import lief
19
+
20
+ from .errors import ExeError
21
+
22
+ FORMAT = "elf"
23
+
24
+ _ET_REL = 1
25
+ _ET_EXEC = 2
26
+ _ET_DYN = 3
27
+
28
+
29
+ def _linked_only(binary: Any) -> None:
30
+ file_type = binary.header.file_type
31
+ if file_type not in (
32
+ lief.ELF.Header.FILE_TYPE.EXEC,
33
+ lief.ELF.Header.FILE_TYPE.DYN,
34
+ ):
35
+ raise ExeError(
36
+ "input is an unlinked ELF object file; "
37
+ "run this command on a linked executable or shared object instead"
38
+ )
39
+
40
+
41
+ def try_parse(path: Path, *, experimental_be: bool = False) -> Any | None:
42
+ """Parse *path* as ELF, or return None when the magic does not match.
43
+
44
+ Big-endian inputs are refused unless *experimental_be* is set (their
45
+ rebuild is unverified and may produce corrupt output).
46
+ """
47
+ with path.open("rb") as f:
48
+ ident = f.read(6)
49
+ if ident[:4] != b"\x7fELF":
50
+ return None
51
+ if ident[5] == 2 and not experimental_be:
52
+ raise ExeError(
53
+ f"cannot edit '{path}': "
54
+ f"big-endian ELF binaries are not supported "
55
+ f"(use --experimental-be to try anyway; "
56
+ f"rebuilding them is unverified)"
57
+ )
58
+ binary = lief.ELF.parse(str(path))
59
+ if binary is None:
60
+ raise ExeError(f"unable to parse '{path}' as ELF")
61
+ _linked_only(binary)
62
+ return binary
63
+
64
+
65
+ def section_names(binary: Any) -> list[str]:
66
+ """Names of all sections in the image."""
67
+ return [section.name for section in binary.sections]
68
+
69
+
70
+ def find_section(binary: Any, name: str) -> Any | None:
71
+ """The section called *name*, or None when it does not exist."""
72
+ return binary.get_section(name)
73
+
74
+
75
+ def _section_index(binary: Any, section: Any) -> int:
76
+ return int(binary.get_section_idx(section))
77
+
78
+
79
+ def check_strippable(binary: Any, section: Any) -> list[Any]:
80
+ """Validate that *section* can be removed; return bound static symbols.
81
+
82
+ Raises ExeError when removal is unsafe.
83
+ """
84
+ sec_type = section.type
85
+ if sec_type not in (
86
+ lief.ELF.Section.TYPE.PROGBITS,
87
+ lief.ELF.Section.TYPE.NOBITS,
88
+ ):
89
+ raise ExeError(
90
+ f"cannot remove '{section.name}': "
91
+ f"only data sections can be removed (section type {sec_type})"
92
+ )
93
+
94
+ index = _section_index(binary, section)
95
+
96
+ bound = [symbol for symbol in binary.symbols if int(symbol.shndx) == index]
97
+ bound_names = {symbol.name for symbol in bound}
98
+
99
+ for symbol in binary.dynamic_symbols:
100
+ if int(symbol.shndx) == index:
101
+ raise ExeError(
102
+ f"cannot remove '{section.name}': dynamic symbol '{symbol.name}' "
103
+ f"references it (dynamic hash tables cannot be rebuilt here)"
104
+ )
105
+
106
+ start = int(section.virtual_address)
107
+ end = start + int(section.size)
108
+ for relocation in binary.relocations:
109
+ address = int(relocation.address)
110
+ if start <= address < end:
111
+ raise ExeError(
112
+ f"cannot remove '{section.name}': "
113
+ f"a relocation targets its address range"
114
+ )
115
+ symbol = relocation.symbol if hasattr(relocation, "symbol") else None
116
+ if symbol is not None and symbol.name in bound_names:
117
+ raise ExeError(
118
+ f"cannot remove '{section.name}': "
119
+ f"a relocation references deleted symbol '{symbol.name}'"
120
+ )
121
+ return bound
122
+
123
+
124
+ def delete_symbols(binary: Any, symbols: list[Any]) -> int:
125
+ """Delete static *symbols* from the symbol table; return the count."""
126
+ for symbol in symbols:
127
+ binary.remove_symtab_symbol(symbol)
128
+ return len(symbols)
129
+
130
+
131
+ def remove_section(binary: Any, section: Any) -> None:
132
+ """Remove *section*, clearing its content so no traces remain."""
133
+ binary.remove(section, clear=True)
134
+
135
+
136
+ def apply_nonload(_binary: Any, section: Any) -> tuple[int, int, bool]:
137
+ """Clear SHF_ALLOC and the section address; return (old_flags, old_addr, changed)."""
138
+ old_flags = int(section.flags)
139
+ old_addr = int(section.virtual_address)
140
+ if not section.has(lief.ELF.Section.FLAGS.ALLOC):
141
+ return old_flags, old_addr, False
142
+ section.flags = lief.ELF.Section.FLAGS(
143
+ old_flags & ~int(lief.ELF.Section.FLAGS.ALLOC)
144
+ )
145
+ section.virtual_address = 0
146
+ return old_flags, old_addr, True
147
+
148
+
149
+ def rebuild(binary: Any) -> bytes:
150
+ """Rebuild the binary and return the new file content."""
151
+ return bytes(binary.write_to_bytes())