Skip to content

Commit 1dd772d

Browse files
authored
Merge pull request #40 from trailofbits/plist
Add experimental support for diffing Apple plists
2 parents de7fda6 + e92b5e4 commit 1dd772d

File tree

7 files changed

+188
-6
lines changed

7 files changed

+188
-6
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![Slack Status](https://empireslacking.herokuapp.com/badge.svg)](https://empireslacking.herokuapp.com)
66

77
Graphtage is a commandline utility and [underlying library](https://trailofbits.github.io/graphtage/latest/library.html)
8-
for semantically comparing and merging tree-like structures, such as JSON, XML, HTML, YAML, and CSS files. Its name is a
8+
for semantically comparing and merging tree-like structures, such as JSON, XML, HTML, YAML, plist, and CSS files. Its name is a
99
portmanteau of “graph” and “graftage”—the latter being the horticultural practice of joining two trees together such
1010
that they grow as one.
1111

docs/_templates/layout.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
{% endfor %}
1818
{% else %}
1919
<dd><a href="/graphtage/latest">latest</a></dd>
20+
<dd><a href="/graphtage/v0.2.5">0.2.5</a></dd>
2021
<dd><a href="/graphtage/v0.2.4">0.2.4</a></dd>
2122
<dd><a href="/graphtage/v0.2.3">0.2.3</a></dd>
2223
<dd><a href="/graphtage/v0.2.2">0.2.2</a></dd>

graphtage/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .version import __version__, VERSION_STRING
88
from . import bounds, edits, expressions, fibonacci, formatter, levenshtein, matching, printer, \
99
search, sequences, tree, utils
10-
from . import csv, json, xml, yaml
10+
from . import csv, json, xml, yaml, plist
1111

1212
import inspect
1313

graphtage/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ def printer_type(*pos_args, **kwargs):
241241
mimetypes.suffix_map['.yaml'] = '.yml'
242242
if '.json5' not in mimetypes.types_map:
243243
mimetypes.add_type('application/json5', '.json5')
244+
if '.plist' not in mimetypes.types_map:
245+
mimetypes.add_type('application/x-plist', '.plist')
244246

245247
if args.from_mime is not None:
246248
from_mime = args.from_mime

graphtage/plist.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""A :class:`graphtage.Filetype` for parsing, diffing, and rendering Apple plist files."""
2+
import os
3+
from xml.parsers.expat import ExpatError
4+
from typing import Optional, Tuple, Union
5+
6+
from plistlib import dumps, load
7+
8+
from . import json
9+
from .edits import Edit, EditCollection, Match
10+
from .graphtage import BoolNode, BuildOptions, Filetype, FloatNode, KeyValuePairNode, IntegerNode, LeafNode, StringNode
11+
from .printer import Printer
12+
from .sequences import SequenceFormatter, SequenceNode
13+
from .tree import ContainerNode, GraphtageFormatter, TreeNode
14+
15+
16+
class PLISTNode(ContainerNode):
17+
def __init__(self, root: TreeNode):
18+
self.root: TreeNode = root
19+
20+
def to_obj(self):
21+
return self.root.to_obj()
22+
23+
def edits(self, node: 'TreeNode') -> Edit:
24+
if isinstance(node, PLISTNode):
25+
return EditCollection(
26+
from_node=self,
27+
to_node=node,
28+
edits=iter((
29+
Match(self, node, 0),
30+
self.root.edits(node.root)
31+
)),
32+
collection=list,
33+
add_to_collection=list.append,
34+
explode_edits=False
35+
)
36+
return self.root.edits(node)
37+
38+
def calculate_total_size(self) -> int:
39+
return self.root.calculate_total_size()
40+
41+
def print(self, printer: Printer):
42+
printer.write(PLIST_HEADER)
43+
self.root.print(printer)
44+
printer.write(PLIST_FOOTER)
45+
46+
def __iter__(self):
47+
yield self.root
48+
49+
def __len__(self) -> int:
50+
return 1
51+
52+
53+
def build_tree(path: str, options: Optional[BuildOptions] = None, *args, **kwargs) -> PLISTNode:
54+
"""Constructs a PLIST tree from an PLIST file."""
55+
with open(path, "rb") as stream:
56+
data = load(stream)
57+
return PLISTNode(json.build_tree(data, options=options, *args, **kwargs))
58+
59+
60+
class PLISTSequenceFormatter(SequenceFormatter):
61+
is_partial = True
62+
63+
def __init__(self):
64+
super().__init__('', '', '')
65+
66+
def print_SequenceNode(self, printer: Printer, node: SequenceNode):
67+
self.parent.print(printer, node)
68+
69+
def print_ListNode(self, printer: Printer, *args, **kwargs):
70+
printer.write("<array>")
71+
super().print_SequenceNode(printer, *args, **kwargs)
72+
printer.write("</array>")
73+
74+
def print_MultiSetNode(self, printer: Printer, *args, **kwargs):
75+
printer.write("<dict>")
76+
super().print_SequenceNode(printer, *args, **kwargs)
77+
printer.write("</dict>")
78+
79+
def print_KeyValuePairNode(self, printer: Printer, node: KeyValuePairNode):
80+
printer.write("<key>")
81+
if isinstance(node.key, StringNode):
82+
printer.write(node.key.object)
83+
else:
84+
self.print(printer, node.key)
85+
printer.write("</key>")
86+
printer.newline()
87+
self.print(printer, node.value)
88+
89+
print_MappingNode = print_MultiSetNode
90+
91+
92+
def _plist_header_footer() -> Tuple[str, str]:
93+
string = "1234567890"
94+
encoded = dumps(string).decode("utf-8")
95+
expected = f"<string>{string}</string>"
96+
body_offset = encoded.find(expected)
97+
if body_offset <= 0:
98+
raise ValueError("Unexpected plist encoding!")
99+
return encoded[:body_offset], encoded[body_offset+len(expected):]
100+
101+
102+
PLIST_HEADER: str
103+
PLIST_FOOTER: str
104+
PLIST_HEADER, PLIST_FOOTER = _plist_header_footer()
105+
106+
107+
class PLISTFormatter(GraphtageFormatter):
108+
sub_format_types = [PLISTSequenceFormatter]
109+
110+
def print(self, printer: Printer, *args, **kwargs):
111+
# PLIST uses an eight-space indent
112+
printer.indent_str = " " * 8
113+
super().print(printer, *args, **kwargs)
114+
115+
@staticmethod
116+
def write_obj(printer: Printer, obj):
117+
encoded = dumps(obj).decode("utf-8")
118+
printer.write(encoded[len(PLIST_HEADER):-len(PLIST_FOOTER)])
119+
120+
def print_StringNode(self, printer: Printer, node: StringNode):
121+
printer.write(f"<string>{node.object}</string>")
122+
123+
def print_IntegerNode(self, printer: Printer, node: IntegerNode):
124+
printer.write(f"<integer>{node.object}</integer>")
125+
126+
def print_FloatNode(self, printer: Printer, node: FloatNode):
127+
printer.write(f"<real>{node.object}</real>")
128+
129+
def print_BoolNode(self, printer, node: BoolNode):
130+
if node.object:
131+
printer.write("<true />")
132+
else:
133+
printer.write("<false />")
134+
135+
def print_LeafNode(self, printer: Printer, node: LeafNode):
136+
self.write_obj(printer, node.object)
137+
138+
def print_PLISTNode(self, printer: Printer, node: PLISTNode):
139+
printer.write(PLIST_HEADER)
140+
self.print(printer, node.root)
141+
printer.write(PLIST_FOOTER)
142+
143+
144+
class PLIST(Filetype):
145+
"""The Apple PLIST filetype."""
146+
def __init__(self):
147+
"""Initializes the PLIST file type.
148+
149+
By default, PLIST associates itself with the "plist" and "application/x-plist" MIME types.
150+
151+
"""
152+
super().__init__(
153+
'plist',
154+
'application/x-plist'
155+
)
156+
157+
def build_tree(self, path: str, options: Optional[BuildOptions] = None) -> TreeNode:
158+
tree = build_tree(path=path, options=options)
159+
for node in tree.dfs():
160+
if isinstance(node, StringNode):
161+
node.quoted = False
162+
return tree
163+
164+
def build_tree_handling_errors(self, path: str, options: Optional[BuildOptions] = None) -> Union[str, TreeNode]:
165+
try:
166+
return self.build_tree(path=path, options=options)
167+
except ExpatError as ee:
168+
return f'Error parsing {os.path.basename(path)}: {ee})'
169+
170+
def get_default_formatter(self) -> PLISTFormatter:
171+
return PLISTFormatter.DEFAULT_INSTANCE

graphtage/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def git_branch() -> Optional[str]:
4848
return None
4949

5050

51-
DEV_BUILD = True
51+
DEV_BUILD = False
5252
"""Sets whether this build is a development build.
5353
5454
This should only be set to :const:`False` to coincide with a release. It should *always* be :const:`False` before

test/test_formatting.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import csv
22
import json
3+
import plistlib
34
import random
45
from functools import partial, wraps
56
from io import StringIO
@@ -44,8 +45,10 @@ def wrapper(self: 'TestFormatting'):
4445
formatter = filetype.get_default_formatter()
4546

4647
for _ in trange(iterations):
47-
orig_obj, str_representation = test_func(self)
48-
with graphtage.utils.Tempfile(str_representation.encode('utf-8')) as t:
48+
orig_obj, representation = test_func(self)
49+
if isinstance(representation, str):
50+
representation = representation.encode("utf-8")
51+
with graphtage.utils.Tempfile(representation) as t:
4952
tree = filetype.build_tree(t)
5053
stream = StringIO()
5154
printer = graphtage.printer.Printer(out_stream=stream, ansi_color=False)
@@ -58,7 +61,7 @@ def wrapper(self: 'TestFormatting'):
5861
self.fail(f"""{filetype_name.upper()} decode error {e}: Original object:
5962
{orig_obj!r}
6063
Expected format:
61-
{str_representation!s}
64+
{representation.decode("utf-8")}
6265
Actual format:
6366
{formatted_str!s}""")
6467
if test_equality:
@@ -245,3 +248,8 @@ def test_yaml_formatting(self):
245248
s = StringIO()
246249
yaml.dump(orig_obj, s, Dumper=graphtage.yaml.Dumper)
247250
return orig_obj, s.getvalue()
251+
252+
@filetype_test(test_equality=False)
253+
def test_plist_formatting(self):
254+
orig_obj = TestFormatting.make_random_obj(force_string_keys=True, exclude_bytes=frozenset('<>/\n&?|@{}[]'))
255+
return orig_obj, plistlib.dumps(orig_obj)

0 commit comments

Comments
 (0)