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