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