xref: /trunk/test/aoo-test/src/aootest/checkers/pkgcheck.py (revision 2cb6d6f119bbe2f78742f1c5c8538cb0d4e72ede)
1*2cb6d6f1SJim Jagielski#**************************************************************
2*2cb6d6f1SJim Jagielski#
3*2cb6d6f1SJim Jagielski#  Licensed to the Apache Software Foundation (ASF) under one
4*2cb6d6f1SJim Jagielski#  or more contributor license agreements.  See the NOTICE file
5*2cb6d6f1SJim Jagielski#  distributed with this work for additional information
6*2cb6d6f1SJim Jagielski#  regarding copyright ownership.  The ASF licenses this file
7*2cb6d6f1SJim Jagielski#  to you under the Apache License, Version 2.0 (the
8*2cb6d6f1SJim Jagielski#  "License"); you may not use this file except in compliance
9*2cb6d6f1SJim Jagielski#  with the License.  You may obtain a copy of the License at
10*2cb6d6f1SJim Jagielski#
11*2cb6d6f1SJim Jagielski#    http://www.apache.org/licenses/LICENSE-2.0
12*2cb6d6f1SJim Jagielski#
13*2cb6d6f1SJim Jagielski#  Unless required by applicable law or agreed to in writing,
14*2cb6d6f1SJim Jagielski#  software distributed under the License is distributed on an
15*2cb6d6f1SJim Jagielski#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16*2cb6d6f1SJim Jagielski#  KIND, either express or implied.  See the License for the
17*2cb6d6f1SJim Jagielski#  specific language governing permissions and limitations
18*2cb6d6f1SJim Jagielski#  under the License.
19*2cb6d6f1SJim Jagielski#
20*2cb6d6f1SJim Jagielski#**************************************************************
21*2cb6d6f1SJim Jagielski
22*2cb6d6f1SJim Jagielski# usage: python -m aootest.checkers.pkgcheck <outdir> <pkg.json>   (needs AOO_JING and AOO_SCHEMA_DIR; tests call check())
23*2cb6d6f1SJim Jagielski# ODF 1.2 Part 3 2.2.1, 3.2, 3.3 and 3.4.1 on every package in <outdir>. Each finding is
24*2cb6d6f1SJim Jagielski# "shall: [rule] ..." or "should: [rule] ...". Schema validity of the manifest is validate.py's job.
25*2cb6d6f1SJim Jagielskiimport sys, os, re, zipfile, json, struct, subprocess, shutil, warnings
26*2cb6d6f1SJim Jagielskifrom xml.etree import ElementTree as ET
27*2cb6d6f1SJim Jagielski
28*2cb6d6f1SJim JagielskiDSIG_SCHEMA_FILE = "OpenDocument-v1.2-os-dsig-schema.rng"
29*2cb6d6f1SJim JagielskiMANI = "{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}"
30*2cb6d6f1SJim JagielskiDSIG = "{urn:oasis:names:tc:opendocument:xmlns:digitalsignature:1.0}"
31*2cb6d6f1SJim JagielskiERR = re.compile(r"^(.*?):(\d+):(\d+): (error|fatal|warning): (.*)$")
32*2cb6d6f1SJim Jagielski
33*2cb6d6f1SJim Jagielski
34*2cb6d6f1SJim Jagielskidef check_package(path, work, jing, dsig_schema):
35*2cb6d6f1SJim Jagielski    """Returns [(entry, level, rule, message)] for one package."""
36*2cb6d6f1SJim Jagielski    out = []
37*2cb6d6f1SJim Jagielski
38*2cb6d6f1SJim Jagielski    def add(entry, level, rule, msg):
39*2cb6d6f1SJim Jagielski        out.append((entry, level, rule, msg))
40*2cb6d6f1SJim Jagielski    z = zipfile.ZipFile(path)
41*2cb6d6f1SJim Jagielski    infos = z.infolist()
42*2cb6d6f1SJim Jagielski    names = [i.filename for i in infos]
43*2cb6d6f1SJim Jagielski    for n in sorted({n for n in names if names.count(n) > 1}):
44*2cb6d6f1SJim Jagielski        add(n, "shall", "zip-duplicate", "zip entry name occurs more than once")
45*2cb6d6f1SJim Jagielski    for i in infos:
46*2cb6d6f1SJim Jagielski        if i.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
47*2cb6d6f1SJim Jagielski            add(i.filename, "shall", "zip-method", "compression method %d is neither STORED nor DEFLATED (2.2.1 A)" % i.compress_type)
48*2cb6d6f1SJim Jagielski        if i.filename.endswith("/"):
49*2cb6d6f1SJim Jagielski            add(i.filename, "should", "zip-dir-entry", "directories have no zip entries (4.3)")
50*2cb6d6f1SJim Jagielski    files = {n for n in names if not n.endswith("/")}
51*2cb6d6f1SJim Jagielski
52*2cb6d6f1SJim Jagielski    # mimetype (3.3)
53*2cb6d6f1SJim Jagielski    mime = None
54*2cb6d6f1SJim Jagielski    if "mimetype" not in files:
55*2cb6d6f1SJim Jagielski        add("mimetype", "should", "mimetype-missing", "no mimetype file (2.2.1 C)")
56*2cb6d6f1SJim Jagielski    else:
57*2cb6d6f1SJim Jagielski        mi = z.getinfo("mimetype")
58*2cb6d6f1SJim Jagielski        first = min(infos, key=lambda i: i.header_offset)
59*2cb6d6f1SJim Jagielski        if first.filename != "mimetype" or mi.header_offset != 0:
60*2cb6d6f1SJim Jagielski            add("mimetype", "shall", "mimetype-first", "mimetype is not the first file of the zip (3.3)")
61*2cb6d6f1SJim Jagielski        with open(path, "rb") as f:
62*2cb6d6f1SJim Jagielski            f.seek(mi.header_offset)
63*2cb6d6f1SJim Jagielski            hdr = f.read(30)
64*2cb6d6f1SJim Jagielski        if len(hdr) == 30 and hdr[:4] == b"PK\x03\x04":
65*2cb6d6f1SJim Jagielski            method, extralen = struct.unpack("<H", hdr[8:10])[0], struct.unpack("<H", hdr[28:30])[0]
66*2cb6d6f1SJim Jagielski            if method != zipfile.ZIP_STORED:
67*2cb6d6f1SJim Jagielski                add("mimetype", "shall", "mimetype-compressed", "mimetype is compressed, local header method %d (3.3)" % method)
68*2cb6d6f1SJim Jagielski            if extralen:
69*2cb6d6f1SJim Jagielski                add("mimetype", "shall", "mimetype-extra", "mimetype has a %d byte extra field in its local header (3.3)" % extralen)
70*2cb6d6f1SJim Jagielski        else:
71*2cb6d6f1SJim Jagielski            add("mimetype", "shall", "mimetype-first", "no local file header at the recorded offset")
72*2cb6d6f1SJim Jagielski        if mi.compress_type != zipfile.ZIP_STORED:
73*2cb6d6f1SJim Jagielski            add("mimetype", "shall", "mimetype-compressed", "mimetype is compressed, central directory method %d (3.3)" % mi.compress_type)
74*2cb6d6f1SJim Jagielski        raw = z.read("mimetype")
75*2cb6d6f1SJim Jagielski        try:
76*2cb6d6f1SJim Jagielski            mime = raw.decode("ascii")
77*2cb6d6f1SJim Jagielski        except UnicodeDecodeError:
78*2cb6d6f1SJim Jagielski            add("mimetype", "shall", "mimetype-ascii", "mimetype content is not ASCII (3.3)")
79*2cb6d6f1SJim Jagielski
80*2cb6d6f1SJim Jagielski    # META-INF contents (2.2.1 D, E)
81*2cb6d6f1SJim Jagielski    for n in sorted(files):
82*2cb6d6f1SJim Jagielski        if n.startswith("META-INF/") and n != "META-INF/manifest.xml" and "signatures" not in n.rsplit("/", 1)[-1]:
83*2cb6d6f1SJim Jagielski            add(n, "shall", "meta-inf-file", "file other than manifest.xml and signature files in META-INF (2.2.1 E)")
84*2cb6d6f1SJim Jagielski
85*2cb6d6f1SJim Jagielski    # manifest (2.2.1 B, 3.2)
86*2cb6d6f1SJim Jagielski    entries = None
87*2cb6d6f1SJim Jagielski    if "META-INF/manifest.xml" not in files:
88*2cb6d6f1SJim Jagielski        add("META-INF/manifest.xml", "shall", "manifest-missing", "no META-INF/manifest.xml (2.2.1 B)")
89*2cb6d6f1SJim Jagielski    else:
90*2cb6d6f1SJim Jagielski        try:
91*2cb6d6f1SJim Jagielski            root = ET.fromstring(z.read("META-INF/manifest.xml"))
92*2cb6d6f1SJim Jagielski            if root.tag != MANI + "manifest":
93*2cb6d6f1SJim Jagielski                add("META-INF/manifest.xml", "shall", "manifest-root", "root element is not manifest:manifest (2.2.1 B.2)")
94*2cb6d6f1SJim Jagielski            else:
95*2cb6d6f1SJim Jagielski                entries = [e for e in root if e.tag == MANI + "file-entry"]
96*2cb6d6f1SJim Jagielski        except ET.ParseError as e:
97*2cb6d6f1SJim Jagielski            add("META-INF/manifest.xml", "shall", "manifest-malformed", "not well-formed: %s (2.2.1 B.1)" % e)
98*2cb6d6f1SJim Jagielski    if entries is not None:
99*2cb6d6f1SJim Jagielski        paths = [e.get(MANI + "full-path") for e in entries]
100*2cb6d6f1SJim Jagielski        for p in sorted({p for p in paths if paths.count(p) > 1}, key=str):
101*2cb6d6f1SJim Jagielski            add("META-INF/manifest.xml", "shall", "manifest-duplicate", "more than one file-entry for %s (3.2)" % p)
102*2cb6d6f1SJim Jagielski        for p in paths:
103*2cb6d6f1SJim Jagielski            if p in ("META-INF/manifest.xml", "mimetype"):
104*2cb6d6f1SJim Jagielski                add("META-INF/manifest.xml", "shall", "manifest-self", "file-entry for %s (3.2)" % p)
105*2cb6d6f1SJim Jagielski            elif p and p.endswith("/") and p != "/":
106*2cb6d6f1SJim Jagielski                if p + "content.xml" not in files and p + "styles.xml" not in files:
107*2cb6d6f1SJim Jagielski                    add("META-INF/manifest.xml", "should", "manifest-dir-entry", "file-entry for %s, a directory without a document (4.3)" % p)
108*2cb6d6f1SJim Jagielski            elif p not in files and p != "/":
109*2cb6d6f1SJim Jagielski                add("META-INF/manifest.xml", "should", "manifest-dangling", "file-entry for %s, which is not in the package (3.2)" % p)
110*2cb6d6f1SJim Jagielski        for n in sorted(files):
111*2cb6d6f1SJim Jagielski            if n != "mimetype" and not n.startswith("META-INF/") and n not in paths:
112*2cb6d6f1SJim Jagielski                add(n, "shall", "manifest-coverage", "no file-entry in the manifest (3.2)")
113*2cb6d6f1SJim Jagielski        root_entry = [e for e in entries if e.get(MANI + "full-path") == "/"]
114*2cb6d6f1SJim Jagielski        if "mimetype" in files and not root_entry:
115*2cb6d6f1SJim Jagielski            add("META-INF/manifest.xml", "shall", "manifest-root-entry", 'no file-entry for "/" although the package has a mimetype file (3.2)')
116*2cb6d6f1SJim Jagielski        for e in root_entry:
117*2cb6d6f1SJim Jagielski            mt = e.get(MANI + "media-type")
118*2cb6d6f1SJim Jagielski            if "mimetype" not in files:
119*2cb6d6f1SJim Jagielski                add("mimetype", "shall", "mimetype-missing", 'file-entry for "/" but no mimetype file (3.3)')
120*2cb6d6f1SJim Jagielski            elif mime is not None and mt != mime:
121*2cb6d6f1SJim Jagielski                add("mimetype", "shall", "mimetype-mismatch", 'mimetype is "%s" but the "/" media-type is "%s" (3.3)' % (mime, mt))
122*2cb6d6f1SJim Jagielski        # encrypted entries (3.4.1)
123*2cb6d6f1SJim Jagielski        for e in entries:
124*2cb6d6f1SJim Jagielski            if e.find(MANI + "encryption-data") is None:
125*2cb6d6f1SJim Jagielski                continue
126*2cb6d6f1SJim Jagielski            p = e.get(MANI + "full-path")
127*2cb6d6f1SJim Jagielski            size = e.get(MANI + "size")
128*2cb6d6f1SJim Jagielski            if size is None or not re.fullmatch(r"[0-9]+", size):
129*2cb6d6f1SJim Jagielski                add(p, "shall", "encrypted-size", "encrypted entry without a valid manifest:size (3.4.1, 4.8.13)")
130*2cb6d6f1SJim Jagielski            if p in files and z.getinfo(p).compress_type != zipfile.ZIP_STORED:
131*2cb6d6f1SJim Jagielski                add(p, "shall", "encrypted-method", "encrypted entry is not flagged STORED in the zip (3.4.1)")
132*2cb6d6f1SJim Jagielski
133*2cb6d6f1SJim Jagielski    # signature files (2.2.1 D)
134*2cb6d6f1SJim Jagielski    sigs = sorted(n for n in files if n.startswith("META-INF/") and "signatures" in n.rsplit("/", 1)[-1])
135*2cb6d6f1SJim Jagielski    for n in sigs:
136*2cb6d6f1SJim Jagielski        try:
137*2cb6d6f1SJim Jagielski            root = ET.fromstring(z.read(n))
138*2cb6d6f1SJim Jagielski        except ET.ParseError as e:
139*2cb6d6f1SJim Jagielski            add(n, "shall", "signature-malformed", "not well-formed: %s (2.2.1 D.1)" % e)
140*2cb6d6f1SJim Jagielski            continue
141*2cb6d6f1SJim Jagielski        if root.tag != DSIG + "document-signatures":
142*2cb6d6f1SJim Jagielski            add(n, "shall", "signature-root", "root element is not dsig:document-signatures (2.2.1 D.2)")
143*2cb6d6f1SJim Jagielski            continue
144*2cb6d6f1SJim Jagielski        p = os.path.join(work, os.path.basename(path), n)
145*2cb6d6f1SJim Jagielski        os.makedirs(os.path.dirname(p), exist_ok=True)
146*2cb6d6f1SJim Jagielski        open(p, "wb").write(z.read(n))
147*2cb6d6f1SJim Jagielski        r = subprocess.run(jing + [dsig_schema, p], capture_output=True, text=True)
148*2cb6d6f1SJim Jagielski        for line in (r.stdout + r.stderr).splitlines():
149*2cb6d6f1SJim Jagielski            m = ERR.match(line)
150*2cb6d6f1SJim Jagielski            if m:
151*2cb6d6f1SJim Jagielski                add(n, "shall", "signature-schema", "%s:%s %s (2.2.1 D.3)" % (m.group(2), m.group(3), m.group(5)))
152*2cb6d6f1SJim Jagielski            elif line.strip():
153*2cb6d6f1SJim Jagielski                add(n, "shall", "signature-schema", line.strip())
154*2cb6d6f1SJim Jagielski    return out
155*2cb6d6f1SJim Jagielski
156*2cb6d6f1SJim Jagielski
157*2cb6d6f1SJim Jagielskidef check(outdir, workdir, jing, schema_dir):
158*2cb6d6f1SJim Jagielski    """Returns the result that main() writes as <pkg.json>. `workdir` must be empty or absent; nothing is deleted."""
159*2cb6d6f1SJim Jagielski    work = os.path.abspath(workdir)
160*2cb6d6f1SJim Jagielski    if os.path.isdir(work) and os.listdir(work):
161*2cb6d6f1SJim Jagielski        raise ValueError("pkgcheck: workdir is not empty: " + work)
162*2cb6d6f1SJim Jagielski    dsig_schema = os.path.join(schema_dir, DSIG_SCHEMA_FILE)
163*2cb6d6f1SJim Jagielski    errors, bad_zip = [], []
164*2cb6d6f1SJim Jagielski    pkgs = sorted(p for p in os.listdir(outdir) if not p.startswith("."))
165*2cb6d6f1SJim Jagielski    for pkg in pkgs:
166*2cb6d6f1SJim Jagielski        try:
167*2cb6d6f1SJim Jagielski            found = check_package(os.path.join(outdir, pkg), work, jing, dsig_schema)
168*2cb6d6f1SJim Jagielski        except zipfile.BadZipFile:
169*2cb6d6f1SJim Jagielski            bad_zip.append(pkg); continue
170*2cb6d6f1SJim Jagielski        for entry, level, rule, msg in found:
171*2cb6d6f1SJim Jagielski            errors.append([pkg + "/" + entry, 0, 0, "%s: [%s] %s" % (level, rule, msg)])
172*2cb6d6f1SJim Jagielski    return {"packages": pkgs, "bad_zip": bad_zip, "errors": errors}
173*2cb6d6f1SJim Jagielski
174*2cb6d6f1SJim Jagielski
175*2cb6d6f1SJim Jagielskidef main(argv=None):
176*2cb6d6f1SJim Jagielski    outdir, pkgjson = sys.argv[1:3] if argv is None else argv
177*2cb6d6f1SJim Jagielski    res = check(outdir, pkgjson[:-5] + ".xml", ["java", "-jar", os.environ["AOO_JING"], "-i"], os.environ["AOO_SCHEMA_DIR"])
178*2cb6d6f1SJim Jagielski    json.dump(res, open(pkgjson, "w"))
179*2cb6d6f1SJim Jagielski    errors, pkgs, bad_zip = res["errors"], res["packages"], res["bad_zip"]
180*2cb6d6f1SJim Jagielski    shall = [e for e in errors if e[3].startswith("shall:")]
181*2cb6d6f1SJim Jagielski    print(f"package check: {len(shall)} shall and {len(errors) - len(shall)} should findings in "
182*2cb6d6f1SJim Jagielski          f"{len({e[0].split('/')[0] for e in errors})} of {len(pkgs)} packages, bad zips: {bad_zip}")
183*2cb6d6f1SJim Jagielski    rules = {}
184*2cb6d6f1SJim Jagielski    for e in errors:
185*2cb6d6f1SJim Jagielski        k = e[3].split("]")[0] + "]"
186*2cb6d6f1SJim Jagielski        rules[k] = rules.get(k, 0) + 1
187*2cb6d6f1SJim Jagielski    for k in sorted(rules):
188*2cb6d6f1SJim Jagielski        print(f"  {rules[k]:6d} {k}")
189*2cb6d6f1SJim Jagielski
190*2cb6d6f1SJim Jagielski
191*2cb6d6f1SJim Jagielskiif __name__ == "__main__":
192*2cb6d6f1SJim Jagielski    main()
193