Skip to content

Commit

Permalink
Merge pull request #40 from trailofbits/plist
Browse files Browse the repository at this point in the history
Add experimental support for diffing Apple plists
  • Loading branch information
ESultanik authored Feb 12, 2021
2 parents de7fda6 + e92b5e4 commit 1dd772d
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 6 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Slack Status](https://empireslacking.herokuapp.com/badge.svg)](https://empireslacking.herokuapp.com)

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

Expand Down
1 change: 1 addition & 0 deletions docs/_templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
{% endfor %}
{% else %}
<dd><a href="/graphtage/latest">latest</a></dd>
<dd><a href="/graphtage/v0.2.5">0.2.5</a></dd>
<dd><a href="/graphtage/v0.2.4">0.2.4</a></dd>
<dd><a href="/graphtage/v0.2.3">0.2.3</a></dd>
<dd><a href="/graphtage/v0.2.2">0.2.2</a></dd>
Expand Down
2 changes: 1 addition & 1 deletion graphtage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .version import __version__, VERSION_STRING
from . import bounds, edits, expressions, fibonacci, formatter, levenshtein, matching, printer, \
search, sequences, tree, utils
from . import csv, json, xml, yaml
from . import csv, json, xml, yaml, plist

import inspect

Expand Down
2 changes: 2 additions & 0 deletions graphtage/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ def printer_type(*pos_args, **kwargs):
mimetypes.suffix_map['.yaml'] = '.yml'
if '.json5' not in mimetypes.types_map:
mimetypes.add_type('application/json5', '.json5')
if '.plist' not in mimetypes.types_map:
mimetypes.add_type('application/x-plist', '.plist')

if args.from_mime is not None:
from_mime = args.from_mime
Expand Down
171 changes: 171 additions & 0 deletions graphtage/plist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""A :class:`graphtage.Filetype` for parsing, diffing, and rendering Apple plist files."""
import os
from xml.parsers.expat import ExpatError
from typing import Optional, Tuple, Union

from plistlib import dumps, load

from . import json
from .edits import Edit, EditCollection, Match
from .graphtage import BoolNode, BuildOptions, Filetype, FloatNode, KeyValuePairNode, IntegerNode, LeafNode, StringNode
from .printer import Printer
from .sequences import SequenceFormatter, SequenceNode
from .tree import ContainerNode, GraphtageFormatter, TreeNode


class PLISTNode(ContainerNode):
def __init__(self, root: TreeNode):
self.root: TreeNode = root

def to_obj(self):
return self.root.to_obj()

def edits(self, node: 'TreeNode') -> Edit:
if isinstance(node, PLISTNode):
return EditCollection(
from_node=self,
to_node=node,
edits=iter((
Match(self, node, 0),
self.root.edits(node.root)
)),
collection=list,
add_to_collection=list.append,
explode_edits=False
)
return self.root.edits(node)

def calculate_total_size(self) -> int:
return self.root.calculate_total_size()

def print(self, printer: Printer):
printer.write(PLIST_HEADER)
self.root.print(printer)
printer.write(PLIST_FOOTER)

def __iter__(self):
yield self.root

def __len__(self) -> int:
return 1


def build_tree(path: str, options: Optional[BuildOptions] = None, *args, **kwargs) -> PLISTNode:
"""Constructs a PLIST tree from an PLIST file."""
with open(path, "rb") as stream:
data = load(stream)
return PLISTNode(json.build_tree(data, options=options, *args, **kwargs))


class PLISTSequenceFormatter(SequenceFormatter):
is_partial = True

def __init__(self):
super().__init__('', '', '')

def print_SequenceNode(self, printer: Printer, node: SequenceNode):
self.parent.print(printer, node)

def print_ListNode(self, printer: Printer, *args, **kwargs):
printer.write("<array>")
super().print_SequenceNode(printer, *args, **kwargs)
printer.write("</array>")

def print_MultiSetNode(self, printer: Printer, *args, **kwargs):
printer.write("<dict>")
super().print_SequenceNode(printer, *args, **kwargs)
printer.write("</dict>")

def print_KeyValuePairNode(self, printer: Printer, node: KeyValuePairNode):
printer.write("<key>")
if isinstance(node.key, StringNode):
printer.write(node.key.object)
else:
self.print(printer, node.key)
printer.write("</key>")
printer.newline()
self.print(printer, node.value)

print_MappingNode = print_MultiSetNode


def _plist_header_footer() -> Tuple[str, str]:
string = "1234567890"
encoded = dumps(string).decode("utf-8")
expected = f"<string>{string}</string>"
body_offset = encoded.find(expected)
if body_offset <= 0:
raise ValueError("Unexpected plist encoding!")
return encoded[:body_offset], encoded[body_offset+len(expected):]


PLIST_HEADER: str
PLIST_FOOTER: str
PLIST_HEADER, PLIST_FOOTER = _plist_header_footer()


class PLISTFormatter(GraphtageFormatter):
sub_format_types = [PLISTSequenceFormatter]

def print(self, printer: Printer, *args, **kwargs):
# PLIST uses an eight-space indent
printer.indent_str = " " * 8
super().print(printer, *args, **kwargs)

@staticmethod
def write_obj(printer: Printer, obj):
encoded = dumps(obj).decode("utf-8")
printer.write(encoded[len(PLIST_HEADER):-len(PLIST_FOOTER)])

def print_StringNode(self, printer: Printer, node: StringNode):
printer.write(f"<string>{node.object}</string>")

def print_IntegerNode(self, printer: Printer, node: IntegerNode):
printer.write(f"<integer>{node.object}</integer>")

def print_FloatNode(self, printer: Printer, node: FloatNode):
printer.write(f"<real>{node.object}</real>")

def print_BoolNode(self, printer, node: BoolNode):
if node.object:
printer.write("<true />")
else:
printer.write("<false />")

def print_LeafNode(self, printer: Printer, node: LeafNode):
self.write_obj(printer, node.object)

def print_PLISTNode(self, printer: Printer, node: PLISTNode):
printer.write(PLIST_HEADER)
self.print(printer, node.root)
printer.write(PLIST_FOOTER)


class PLIST(Filetype):
"""The Apple PLIST filetype."""
def __init__(self):
"""Initializes the PLIST file type.
By default, PLIST associates itself with the "plist" and "application/x-plist" MIME types.
"""
super().__init__(
'plist',
'application/x-plist'
)

def build_tree(self, path: str, options: Optional[BuildOptions] = None) -> TreeNode:
tree = build_tree(path=path, options=options)
for node in tree.dfs():
if isinstance(node, StringNode):
node.quoted = False
return tree

def build_tree_handling_errors(self, path: str, options: Optional[BuildOptions] = None) -> Union[str, TreeNode]:
try:
return self.build_tree(path=path, options=options)
except ExpatError as ee:
return f'Error parsing {os.path.basename(path)}: {ee})'

def get_default_formatter(self) -> PLISTFormatter:
return PLISTFormatter.DEFAULT_INSTANCE
2 changes: 1 addition & 1 deletion graphtage/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def git_branch() -> Optional[str]:
return None


DEV_BUILD = True
DEV_BUILD = False
"""Sets whether this build is a development build.
This should only be set to :const:`False` to coincide with a release. It should *always* be :const:`False` before
Expand Down
14 changes: 11 additions & 3 deletions test/test_formatting.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import csv
import json
import plistlib
import random
from functools import partial, wraps
from io import StringIO
Expand Down Expand Up @@ -44,8 +45,10 @@ def wrapper(self: 'TestFormatting'):
formatter = filetype.get_default_formatter()

for _ in trange(iterations):
orig_obj, str_representation = test_func(self)
with graphtage.utils.Tempfile(str_representation.encode('utf-8')) as t:
orig_obj, representation = test_func(self)
if isinstance(representation, str):
representation = representation.encode("utf-8")
with graphtage.utils.Tempfile(representation) as t:
tree = filetype.build_tree(t)
stream = StringIO()
printer = graphtage.printer.Printer(out_stream=stream, ansi_color=False)
Expand All @@ -58,7 +61,7 @@ def wrapper(self: 'TestFormatting'):
self.fail(f"""{filetype_name.upper()} decode error {e}: Original object:
{orig_obj!r}
Expected format:
{str_representation!s}
{representation.decode("utf-8")}
Actual format:
{formatted_str!s}""")
if test_equality:
Expand Down Expand Up @@ -245,3 +248,8 @@ def test_yaml_formatting(self):
s = StringIO()
yaml.dump(orig_obj, s, Dumper=graphtage.yaml.Dumper)
return orig_obj, s.getvalue()

@filetype_test(test_equality=False)
def test_plist_formatting(self):
orig_obj = TestFormatting.make_random_obj(force_string_keys=True, exclude_bytes=frozenset('<>/\n&?|@{}[]'))
return orig_obj, plistlib.dumps(orig_obj)

0 comments on commit 1dd772d

Please sign in to comment.