xref: /trunk/test/odf-validate/idcheck.py (revision 0e16a31d78a652c56c5c0fc79c752f48b9048df9)
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: idcheck.py <outdir> <ids.json>
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
28D = os.path.dirname(os.path.abspath(__file__))
29STREAMS = {"content.xml", "styles.xml", "meta.xml", "settings.xml", "manifest.xml"}
30XMLID = "http://www.w3.org/XML/1998/namespace id"
31# XML 1.0 5th ed. NameStartChar/NameChar without ':'
32_S = "A-Z_a-zÀ-ÖØ-öø-˿Ͱ-ͽͿ-῿‌-‍⁰-↏Ⰰ-⿯、-퟿豈-﷏ﷰ-�\U00010000-\U000EFFFF"
33NCNAME = re.compile("[" + _S + "][" + _S + r"\-.0-9·̀-ͯ‿-⁀]*\Z")
34
35
36def check_stream(data):
37    """Returns [(line, col, message)]."""
38    errs, seen = [], {}
39    p = expat.ParserCreate(namespace_separator=" ")
40
41    def start(name, attrs):
42        if XMLID not in attrs:
43            return
44        # xml:id 1.0 sec. 4: normalize like an ID-typed attribute
45        v = " ".join(attrs[XMLID].split())
46        pos = (p.CurrentLineNumber, p.CurrentColumnNumber)
47        if not NCNAME.match(v):
48            errs.append(pos + ('xml:id "%s" is not an NCName' % v,))
49        elif v in seen:
50            errs.append(pos + ('xml:id "%s" duplicates line %d' % (v, seen[v]),))
51        else:
52            seen[v] = pos[0]
53    p.StartElementHandler = start
54    try:
55        p.Parse(data, True)
56    except expat.ExpatError as e:
57        errs.append((e.lineno, e.offset, "not namespace-well-formed: " + expat.ErrorString(e.code)))
58    return errs
59
60
61def main():
62    outdir, idsjson = sys.argv[1], sys.argv[2]
63    if not os.path.abspath(idsjson).startswith(os.path.join(D, "runs") + os.sep):
64        sys.exit("idcheck.py: <ids.json> must live under " + os.path.join(D, "runs"))
65    errors, bad_zip, nstreams = [], [], 0
66    pkgs = sorted(p for p in os.listdir(outdir) if not p.startswith("."))
67    for pkg in pkgs:
68        try:
69            z = zipfile.ZipFile(os.path.join(outdir, pkg))
70        except Exception:
71            bad_zip.append(pkg); continue
72        for name in z.namelist():
73            base = name.rsplit("/", 1)[-1]
74            if base not in STREAMS and not (name.startswith("META-INF/") and "signatures" in base):
75                continue
76            nstreams += 1
77            for line, col, msg in check_stream(z.read(name)):
78                errors.append([pkg + "/" + name, line, col, msg])
79    json.dump({"packages": pkgs, "bad_zip": bad_zip, "nstreams": nstreams, "errors": errors}, open(idsjson, "w"))
80    print(f"xml:id / namespace check: {len(errors)} errors in {len({e[0].split('/')[0] for e in errors})} of {len(pkgs)} packages, "
81          f"streams checked: {nstreams}, bad zips: {bad_zip}")
82
83
84main()
85