binman: Support listing an image

Add support for listing the entries in an image. This relies on the image
having an FDT map.

Signed-off-by: Simon Glass <sjg@chromium.org>
diff --git a/tools/binman/README b/tools/binman/README
index 9860633..146e0fd 100644
--- a/tools/binman/README
+++ b/tools/binman/README
@@ -490,6 +490,49 @@
 	binman entry-docs >tools/binman/README.entries
 
 
+Listing images
+--------------
+
+It is possible to list the entries in an existing firmware image created by
+binman, provided that there is an 'fdtmap' entry in the image. For example:
+
+    $ binman ls -i image.bin
+    Name              Image-pos  Size  Entry-type    Offset  Uncomp-size
+    ----------------------------------------------------------------------
+    main-section                  c00  section            0
+      u-boot                  0     4  u-boot             0
+      section                     5fc  section            4
+        cbfs                100   400  cbfs               0
+          u-boot            138     4  u-boot            38
+          u-boot-dtb        180   108  u-boot-dtb        80          3b5
+        u-boot-dtb          500   1ff  u-boot-dtb       400          3b5
+      fdtmap                6fc   381  fdtmap           6fc
+      image-header          bf8     8  image-header     bf8
+
+This shows the hierarchy of the image, the position, size and type of each
+entry, the offset of each entry within its parent and the uncompressed size if
+the entry is compressed.
+
+It is also possible to list just some files in an image, e.g.
+
+    $ binman ls -i image.bin section/cbfs
+    Name              Image-pos  Size  Entry-type  Offset  Uncomp-size
+    --------------------------------------------------------------------
+        cbfs                100   400  cbfs             0
+          u-boot            138     4  u-boot          38
+          u-boot-dtb        180   108  u-boot-dtb      80          3b5
+
+or with wildcards:
+
+    $ binman ls -i image.bin "*cb*" "*head*"
+    Name              Image-pos  Size  Entry-type    Offset  Uncomp-size
+    ----------------------------------------------------------------------
+        cbfs                100   400  cbfs               0
+          u-boot            138     4  u-boot            38
+          u-boot-dtb        180   108  u-boot-dtb        80          3b5
+      image-header          bf8     8  image-header     bf8
+
+
 Hashing Entries
 ---------------
 
@@ -825,7 +868,6 @@
 - Add an option to decode an image into the constituent binaries
 - Support building an image for a board (-b) more completely, with a
   configurable build directory
-- Support listing files in images
 - Support logging of binman's operations, with different levels of verbosity
 - Support updating binaries in an image (with no size change / repacking)
 - Support updating binaries in an image (with repacking)
diff --git a/tools/binman/cmdline.py b/tools/binman/cmdline.py
index a002105..508232e 100644
--- a/tools/binman/cmdline.py
+++ b/tools/binman/cmdline.py
@@ -65,6 +65,12 @@
     entry_parser = subparsers.add_parser('entry-docs',
         help='Write out entry documentation (see README.entries)')
 
+    list_parser = subparsers.add_parser('ls', help='List files in an image')
+    list_parser.add_argument('-i', '--image', type=str, required=True,
+                             help='Image filename to list')
+    list_parser.add_argument('paths', type=str, nargs='*',
+                             help='Paths within file to list (wildcard)')
+
     test_parser = subparsers.add_parser('test', help='Run tests')
     test_parser.add_argument('-P', '--processes', type=int,
         help='set number of processes to use for running tests')
diff --git a/tools/binman/control.py b/tools/binman/control.py
index 35faf11..813c8b1 100644
--- a/tools/binman/control.py
+++ b/tools/binman/control.py
@@ -67,6 +67,37 @@
     from entry import Entry
     Entry.WriteDocs(modules, test_missing)
 
+
+def ListEntries(image_fname, entry_paths):
+    """List the entries in an image
+
+    This decodes the supplied image and displays a table of entries from that
+    image, preceded by a header.
+
+    Args:
+        image_fname: Image filename to process
+        entry_paths: List of wildcarded paths (e.g. ['*dtb*', 'u-boot*',
+                                                     'section/u-boot'])
+    """
+    image = Image.FromFile(image_fname)
+
+    entries, lines, widths = image.GetListEntries(entry_paths)
+
+    num_columns = len(widths)
+    for linenum, line in enumerate(lines):
+        if linenum == 1:
+            # Print header line
+            print('-' * (sum(widths) + num_columns * 2))
+        out = ''
+        for i, item in enumerate(line):
+            width = -widths[i]
+            if item.startswith('>'):
+                width = -width
+                item = item[1:]
+            txt = '%*s  ' % (width, item)
+            out += txt
+        print(out.rstrip())
+
 def Binman(args):
     """The main control code for binman
 
@@ -87,6 +118,10 @@
         command.Run(pager, fname)
         return 0
 
+    if args.cmd == 'ls':
+        ListEntries(args.image, args.paths)
+        return 0
+
     # Try to figure out which device tree contains our image description
     if args.dt:
         dtb_fname = args.dt
diff --git a/tools/binman/ftest.py b/tools/binman/ftest.py
index 4f58ce3..ad82804 100644
--- a/tools/binman/ftest.py
+++ b/tools/binman/ftest.py
@@ -2341,6 +2341,88 @@
             image = Image.FromFile(image_fname)
         self.assertIn("Cannot find FDT map in image", str(e.exception))
 
+    def testListCmd(self):
+        """Test listing the files in an image using an Fdtmap"""
+        self._CheckLz4()
+        data = self._DoReadFileRealDtb('130_list_fdtmap.dts')
+
+        # lz4 compression size differs depending on the version
+        image = control.images['image']
+        entries = image.GetEntries()
+        section_size = entries['section'].size
+        fdt_size = entries['section'].GetEntries()['u-boot-dtb'].size
+        fdtmap_offset = entries['fdtmap'].offset
+
+        image_fname = tools.GetOutputFilename('image.bin')
+        with test_util.capture_sys_output() as (stdout, stderr):
+            self._DoBinman('ls', '-i', image_fname)
+        lines = stdout.getvalue().splitlines()
+        expected = [
+'Name              Image-pos  Size  Entry-type    Offset  Uncomp-size',
+'----------------------------------------------------------------------',
+'main-section              0   c00  section            0',
+'  u-boot                  0     4  u-boot             0',
+'  section               100   %x  section          100' % section_size,
+'    cbfs                100   400  cbfs               0',
+'      u-boot            138     4  u-boot            38',
+'      u-boot-dtb        180   10f  u-boot-dtb        80          3c9',
+'    u-boot-dtb          500   %x  u-boot-dtb       400          3c9' % fdt_size,
+'  fdtmap                %x   395  fdtmap           %x' %
+        (fdtmap_offset, fdtmap_offset),
+'  image-header          bf8     8  image-header     bf8',
+            ]
+        self.assertEqual(expected, lines)
+
+    def testListCmdFail(self):
+        """Test failing to list an image"""
+        self._DoReadFile('005_simple.dts')
+        image_fname = tools.GetOutputFilename('image.bin')
+        with self.assertRaises(ValueError) as e:
+            self._DoBinman('ls', '-i', image_fname)
+        self.assertIn("Cannot find FDT map in image", str(e.exception))
+
+    def _RunListCmd(self, paths, expected):
+        """List out entries and check the result
+
+        Args:
+            paths: List of paths to pass to the list command
+            expected: Expected list of filenames to be returned, in order
+        """
+        self._CheckLz4()
+        self._DoReadFileRealDtb('130_list_fdtmap.dts')
+        image_fname = tools.GetOutputFilename('image.bin')
+        image = Image.FromFile(image_fname)
+        lines = image.GetListEntries(paths)[1]
+        files = [line[0].strip() for line in lines[1:]]
+        self.assertEqual(expected, files)
+
+    def testListCmdSection(self):
+        """Test listing the files in a section"""
+        self._RunListCmd(['section'],
+            ['section', 'cbfs', 'u-boot', 'u-boot-dtb', 'u-boot-dtb'])
+
+    def testListCmdFile(self):
+        """Test listing a particular file"""
+        self._RunListCmd(['*u-boot-dtb'], ['u-boot-dtb', 'u-boot-dtb'])
+
+    def testListCmdWildcard(self):
+        """Test listing a wildcarded file"""
+        self._RunListCmd(['*boot*'],
+            ['u-boot', 'u-boot', 'u-boot-dtb', 'u-boot-dtb'])
+
+    def testListCmdWildcardMulti(self):
+        """Test listing a wildcarded file"""
+        self._RunListCmd(['*cb*', '*head*'],
+            ['cbfs', 'u-boot', 'u-boot-dtb', 'image-header'])
+
+    def testListCmdEmpty(self):
+        """Test listing a wildcarded file"""
+        self._RunListCmd(['nothing'], [])
+
+    def testListCmdPath(self):
+        """Test listing the files in a sub-entry of a section"""
+        self._RunListCmd(['section/cbfs'], ['cbfs', 'u-boot', 'u-boot-dtb'])
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tools/binman/image.py b/tools/binman/image.py
index 487290b..2c5668e 100644
--- a/tools/binman/image.py
+++ b/tools/binman/image.py
@@ -8,6 +8,7 @@
 from __future__ import print_function
 
 from collections import OrderedDict
+import fnmatch
 from operator import attrgetter
 import re
 import sys
@@ -147,3 +148,152 @@
         entries = []
         self.ListEntries(entries, 0)
         return entries
+
+    def FindEntryPath(self, entry_path):
+        """Find an entry at a given path in the image
+
+        Args:
+            entry_path: Path to entry (e.g. /ro-section/u-boot')
+
+        Returns:
+            Entry object corresponding to that past
+
+        Raises:
+            ValueError if no entry found
+        """
+        parts = entry_path.split('/')
+        entries = self.GetEntries()
+        parent = '/'
+        for part in parts:
+            entry = entries.get(part)
+            if not entry:
+                raise ValueError("Entry '%s' not found in '%s'" %
+                                 (part, parent))
+            parent = entry.GetPath()
+            entries = entry.GetEntries()
+        return entry
+
+    def ReadData(self, decomp=True):
+        return self._data
+
+    def GetListEntries(self, entry_paths):
+        """List the entries in an image
+
+        This decodes the supplied image and returns a list of entries from that
+        image, preceded by a header.
+
+        Args:
+            entry_paths: List of paths to match (each can have wildcards). Only
+                entries whose names match one of these paths will be printed
+
+        Returns:
+            String error message if something went wrong, otherwise
+            3-Tuple:
+                List of EntryInfo objects
+                List of lines, each
+                    List of text columns, each a string
+                List of widths of each column
+        """
+        def _EntryToStrings(entry):
+            """Convert an entry to a list of strings, one for each column
+
+            Args:
+                entry: EntryInfo object containing information to output
+
+            Returns:
+                List of strings, one for each field in entry
+            """
+            def _AppendHex(val):
+                """Append a hex value, or an empty string if val is None
+
+                Args:
+                    val: Integer value, or None if none
+                """
+                args.append('' if val is None else '>%x' % val)
+
+            args = ['  ' * entry.indent + entry.name]
+            _AppendHex(entry.image_pos)
+            _AppendHex(entry.size)
+            args.append(entry.etype)
+            _AppendHex(entry.offset)
+            _AppendHex(entry.uncomp_size)
+            return args
+
+        def _DoLine(lines, line):
+            """Add a line to the output list
+
+            This adds a line (a list of columns) to the output list. It also updates
+            the widths[] array with the maximum width of each column
+
+            Args:
+                lines: List of lines to add to
+                line: List of strings, one for each column
+            """
+            for i, item in enumerate(line):
+                widths[i] = max(widths[i], len(item))
+            lines.append(line)
+
+        def _NameInPaths(fname, entry_paths):
+            """Check if a filename is in a list of wildcarded paths
+
+            Args:
+                fname: Filename to check
+                entry_paths: List of wildcarded paths (e.g. ['*dtb*', 'u-boot*',
+                                                             'section/u-boot'])
+
+            Returns:
+                True if any wildcard matches the filename (using Unix filename
+                    pattern matching, not regular expressions)
+                False if not
+            """
+            for path in entry_paths:
+                if fnmatch.fnmatch(fname, path):
+                    return True
+            return False
+
+        entries = self.BuildEntryList()
+
+        # This is our list of lines. Each item in the list is a list of strings, one
+        # for each column
+        lines = []
+        HEADER = ['Name', 'Image-pos', 'Size', 'Entry-type', 'Offset',
+                  'Uncomp-size']
+        num_columns = len(HEADER)
+
+        # This records the width of each column, calculated as the maximum width of
+        # all the strings in that column
+        widths = [0] * num_columns
+        _DoLine(lines, HEADER)
+
+        # We won't print anything unless it has at least this indent. So at the
+        # start we will print nothing, unless a path matches (or there are no
+        # entry paths)
+        MAX_INDENT = 100
+        min_indent = MAX_INDENT
+        path_stack = []
+        path = ''
+        indent = 0
+        selected_entries = []
+        for entry in entries:
+            if entry.indent > indent:
+                path_stack.append(path)
+            elif entry.indent < indent:
+                path_stack.pop()
+            if path_stack:
+                path = path_stack[-1] + '/' + entry.name
+            indent = entry.indent
+
+            # If there are entry paths to match and we are not looking at a
+            # sub-entry of a previously matched entry, we need to check the path
+            if entry_paths and indent <= min_indent:
+                if _NameInPaths(path[1:], entry_paths):
+                    # Print this entry and all sub-entries (=higher indent)
+                    min_indent = indent
+                else:
+                    # Don't print this entry, nor any following entries until we get
+                    # a path match
+                    min_indent = MAX_INDENT
+                    continue
+            _DoLine(lines, _EntryToStrings(entry))
+            selected_entries.append(entry)
+        return selected_entries, lines, widths
diff --git a/tools/binman/test/130_list_fdtmap.dts b/tools/binman/test/130_list_fdtmap.dts
new file mode 100644
index 0000000..449fccc
--- /dev/null
+++ b/tools/binman/test/130_list_fdtmap.dts
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: GPL-2.0+
+
+/dts-v1/;
+
+/ {
+	#address-cells = <1>;
+	#size-cells = <1>;
+
+	binman {
+		size = <0xc00>;
+		u-boot {
+		};
+		section {
+			align = <0x100>;
+			cbfs {
+				size = <0x400>;
+				u-boot {
+					cbfs-type = "raw";
+				};
+				u-boot-dtb {
+					cbfs-type = "raw";
+					cbfs-compress = "lzma";
+					cbfs-offset = <0x80>;
+				};
+			};
+			u-boot-dtb {
+				compress = "lz4";
+			};
+		};
+		fdtmap {
+		};
+		image-header {
+			location = "end";
+		};
+	};
+};