xref: /trunk/test/aoo-test/src/aootest/checkers/idcheck.py (revision 2cb6d6f119bbe2f78742f1c5c8538cb0d4e72ede)
1#**************************************************************
2#
3#  Licensed to the Apache Software Foundation (ASF) under one
4#  or more contributor license agreements.  See the NOTICE file
5#  distributed with this work for additional information
6#  regarding copyright ownership.  The ASF licenses this file
7#  to you under the Apache License, Version 2.0 (the
8#  "License"); you may not use this file except in compliance
9#  with the License.  You may obtain a copy of the License at
10#
11#    http://www.apache.org/licenses/LICENSE-2.0
12#
13#  Unless required by applicable law or agreed to in writing,
14#  software distributed under the License is distributed on an
15#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16#  KIND, either express or implied.  See the License for the
17#  specific language governing permissions and limitations
18#  under the License.
19#
20#**************************************************************
21
22# usage: python -m aootest.checkers.idcheck <outdir> <ids.json>   (tests call check(outdir))
23# ODF 1.2 Part 1 2.2.1 D.1 and D.2, and Part 3 2.2.1 F, which `jing -i` does not check: each XML stream is
24# parsed namespace-aware, and its xml:id values must be NCNames, unique within the stream.
25import sys, os, re, zipfile, json
26from xml.parsers import expat
27
28STREAMS = {"content.xml", "styles.xml", "meta.xml", "settings.xml", "manifest.xml"}
29XMLID = "http://www.w3.org/XML/1998/namespace id"
30# XML 1.0 5th ed. NameStartChar/NameChar without ':'
31_S = "A-Z_a-zÀ-ÖØ-öø-˿Ͱ-ͽͿ-῿‌-‍⁰-↏Ⰰ-⿯、-퟿豈-﷏ﷰ-�\U00010000-\U000EFFFF"
32NCNAME = re.compile("[" + _S + "][" + _S + r"\-.0-9·̀-ͯ‿-⁀]*\Z")
33
34
35def check_stream(data):
36    """Returns [(line, col, message)]."""
37    errs, seen = [], {}
38    p = expat.ParserCreate(namespace_separator=" ")
39
40    def start(name, attrs):
41        if XMLID not in attrs:
42            return
43        # xml:id 1.0 sec. 4: normalize like an ID-typed attribute
44        v = " ".join(attrs[XMLID].split())
45        pos = (p.CurrentLineNumber, p.CurrentColumnNumber)
46        if not NCNAME.match(v):
47            errs.append(pos + ('xml:id "%s" is not an NCName' % v,))
48        elif v in seen:
49            errs.append(pos + ('xml:id "%s" duplicates line %d' % (v, seen[v]),))
50        else:
51            seen[v] = pos[0]
52    p.StartElementHandler = start
53    try:
54        p.Parse(data, True)
55    except expat.ExpatError as e:
56        errs.append((e.lineno, e.offset, "not namespace-well-formed: " + expat.ErrorString(e.code)))
57    return errs
58
59
60def check(outdir):
61    """Returns the result that main() writes as <ids.json>."""
62    errors, bad_zip, nstreams = [], [], 0
63    pkgs = sorted(p for p in os.listdir(outdir) if not p.startswith("."))
64    for pkg in pkgs:
65        try:
66            z = zipfile.ZipFile(os.path.join(outdir, pkg))
67        except Exception:
68            bad_zip.append(pkg); continue
69        for name in z.namelist():
70            base = name.rsplit("/", 1)[-1]
71            if base not in STREAMS and not (name.startswith("META-INF/") and "signatures" in base):
72                continue
73            nstreams += 1
74            for line, col, msg in check_stream(z.read(name)):
75                errors.append([pkg + "/" + name, line, col, msg])
76    return {"packages": pkgs, "bad_zip": bad_zip, "nstreams": nstreams, "errors": errors}
77
78
79def main(argv=None):
80    outdir, idsjson = sys.argv[1:3] if argv is None else argv
81    res = check(outdir)
82    json.dump(res, open(idsjson, "w"))
83    errors, pkgs, bad_zip, nstreams = res["errors"], res["packages"], res["bad_zip"], res["nstreams"]
84    print(f"xml:id / namespace check: {len(errors)} errors in {len({e[0].split('/')[0] for e in errors})} of {len(pkgs)} packages, "
85          f"streams checked: {nstreams}, bad zips: {bad_zip}")
86
87
88if __name__ == "__main__":
89    main()
90