Make cstruct_test pass on both Python 2 and Python 3.

This is in its own CL because most of the other tests depend on
cstruct working.

Test: python2 cstruct_test.py && python3 cstruct_test.py
Test: existing python2 tests pass on android14-5.15
Change-Id: I1e95d4a645456507e2e14f54456a77916fd4a725
diff --git a/net/test/cstruct.py b/net/test/cstruct.py
index 662f2a1..c10667a 100644
--- a/net/test/cstruct.py
+++ b/net/test/cstruct.py
@@ -67,6 +67,7 @@
 >>>
 """
 
+import binascii
 import ctypes
 import string
 import struct
@@ -104,10 +105,18 @@
 def Struct(name, fmt, fieldnames, substructs={}):
   """Function that returns struct classes."""
 
-  class CStruct(object):
-    """Class representing a C-like structure."""
+  # Hack to make struct classes use the StructMetaclass class on both python2 and
+  # python3. This is needed because in python2 the metaclass is assigned in the
+  # class definition, but in python3 it's passed into the constructor via
+  # keyword argument. Works by making all structs subclass CStructSuperclass,
+  # whose __new__ method uses StructMetaclass as its metaclass.
+  #
+  # A better option would be to use six.with_metaclass, but the existing python2
+  # VM image doesn't have the six module.
+  CStructSuperclass = type.__new__(StructMetaclass, 'unused', (), {})
 
-    __metaclass__ = StructMetaclass
+  class CStruct(CStructSuperclass):
+    """Class representing a C-like structure."""
 
     # Name of the struct.
     _name = name
@@ -132,8 +141,11 @@
         laststructindex += 1
         _format += "%ds" % len(_nested[index])
       elif fmt[i] == "A":
-        # Null-terminated ASCII string.
-        index = CalcNumElements(fmt[:i])
+        # Null-terminated ASCII string. Remove digits before the A, so we don't
+        # call CalcNumElements on an (invalid) format that ends with a digit.
+        start = i
+        while start > 0 and fmt[start - 1].isdigit(): start -= 1
+        index = CalcNumElements(fmt[:start])
         _asciiz.add(index)
         _format += "s"
       else:
@@ -250,13 +262,22 @@
       return struct.pack(self._format, *values)
 
     def __str__(self):
+
+      def HasNonPrintableChar(s):
+        for c in s:
+          # Iterating over bytes yields chars in python2 but ints in python3.
+          if isinstance(c, int): c = chr(c)
+          if c not in string.printable: return True
+        return False
+
       def FieldDesc(index, name, value):
-        if isinstance(value, bytes):
+        if isinstance(value, bytes) or isinstance(value, str):
           if index in self._asciiz:
-            value = value.rstrip(b"\x00")
-          elif any(c not in string.printable for c in value):
-            value = value.encode("hex")
-        return "%s=%s" % (name, value)
+            # TODO: use "backslashreplace" when python 2 is no longer supported.
+            value = value.rstrip(b"\x00").decode(errors="ignore")
+          elif HasNonPrintableChar(value):
+            value = binascii.hexlify(value).decode()
+        return "%s=%s" % (name, str(value))
 
       descriptions = [
           FieldDesc(i, n, v) for i, (n, v) in
diff --git a/net/test/cstruct_test.py b/net/test/cstruct_test.py
index 222ee19..da684c6 100755
--- a/net/test/cstruct_test.py
+++ b/net/test/cstruct_test.py
@@ -14,6 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import binascii
 import unittest
 
 import cstruct
@@ -87,9 +88,9 @@
         " nest2=Nested(word1=33214, nest2=TestStructA(byte1=3, int2=4),"
         " nest3=TestStructB(byte1=7, int2=33627591), int4=-55), byte3=252)")
     self.assertEqual(expected, str(d))
-    expected = ("01" "02000000"
-                "81be" "03" "04000000"
-                "07" "c71d0102" "ffffffc9" "fc").decode("hex")
+    expected = binascii.unhexlify("01" "02000000"
+                                  "81be" "03" "04000000"
+                                  "07" "c71d0102" "ffffffc9" "fc")
     self.assertEqual(expected, d.Pack())
     unpacked = DoubleNested(expected)
     self.CheckEquals(unpacked, d)
@@ -110,6 +111,12 @@
                 " int3=12345, ascii4=hello\x00visible123, word5=33210)")
     self.assertEqual(expected, str(t))
 
+    embedded_non_ascii = b"hello\xc0visible123"
+    t = TestStruct((2, embedded_non_ascii, 12345, embeddednull, 33210))
+    expected = ("TestStruct(byte1=2, string2=68656c6c6fc076697369626c65313233,"
+                " int3=12345, ascii4=hello\x00visible123, word5=33210)")
+    self.assertEqual(expected, str(t))
+
   def testZeroInitialization(self):
     TestStruct = cstruct.Struct("TestStruct", "B16si16AH",
                                 "byte1 string2 int3 ascii4 word5")
@@ -124,17 +131,17 @@
   def testKeywordInitialization(self):
     TestStruct = cstruct.Struct("TestStruct", "=B16sIH",
                                 "byte1 string2 int3 word4")
-    text = "hello world! ^_^"
-    text_bytes = text.encode("hex")
+    bytes = b"hello world! ^_^"
+    hex_bytes = binascii.hexlify(bytes)
 
     # Populate all fields
-    t1 = TestStruct(byte1=1, string2=text, int3=0xFEDCBA98, word4=0x1234)
-    expected = ("01" + text_bytes + "98BADCFE" "3412").decode("hex")
+    t1 = TestStruct(byte1=1, string2=bytes, int3=0xFEDCBA98, word4=0x1234)
+    expected = binascii.unhexlify(b"01" + hex_bytes + b"98BADCFE" b"3412")
     self.assertEqual(expected, t1.Pack())
 
     # Partially populated
-    t1 = TestStruct(string2=text, word4=0x1234)
-    expected = ("00" + text_bytes + "00000000" "3412").decode("hex")
+    t1 = TestStruct(string2=bytes, word4=0x1234)
+    expected = binascii.unhexlify(b"00" + hex_bytes + b"00000000" b"3412")
     self.assertEqual(expected, t1.Pack())
 
   def testCstructOffset(self):