1 #!/usr/bin/env nix-shell
2 #! nix-shell -i "python3 -I" -p "python3.withPackages(p: with p; [ rich structlog ])"
4 from abc
import ABC
, abstractmethod
5 from contextlib
import contextmanager
6 from pathlib
import Path
7 from structlog
.contextvars
import bound_contextvars
as log_context
8 from typing
import ClassVar
, List
, Tuple
10 import hashlib
, logging
, re
, structlog
13 logger
= structlog
.getLogger("sha-to-SRI")
17 alphabet
: ClassVar
[str]
22 return cls
.__name
__.lower()
24 def toSRI(self
, s
: str) -> str:
25 digest
= self
.decode(s
)
26 assert len(digest
) == self
.n
28 from base64
import b64encode
30 return f
"{self.hashName}-{b64encode(digest).decode()}"
33 def all(cls
, h
) -> "List[Encoding]":
34 return [c(h
) for c
in cls
.__subclasses
__()]
36 def __init__(self
, h
):
37 self
.n
= h
.digest_size
38 self
.hashName
= h
.name
42 def length(self
) -> int: ...
45 def regex(self
) -> str:
46 return f
"[{self.alphabet}]{{{self.length}}}"
49 def decode(self
, s
: str) -> bytes
: ...
52 class Nix32(Encoding
):
53 alphabet
= "0123456789abcdfghijklmnpqrsvwxyz"
54 inverted
= {c
: i
for i
, c
in enumerate(alphabet
)}
58 return 1 + (8 * self
.n
) // 5
60 def decode(self
, s
: str):
61 assert len(s
) == self
.length
62 out
= bytearray(self
.n
)
64 for n
, c
in enumerate(reversed(s
)):
65 digit
= self
.inverted
[c
]
66 i
, j
= divmod(5 * n
, 8)
67 out
[i
] = out
[i
] |
(digit
<< j
) & 0xFF
68 rem
= digit
>> (8 - j
)
74 raise ValueError(f
"Invalid nix32 hash: '{s}'")
80 alphabet
= "0-9A-Fa-f"
86 def decode(self
, s
: str):
87 from binascii
import unhexlify
92 class Base64(Encoding
):
93 alphabet
= "A-Za-z0-9+/"
96 def format(self
) -> Tuple
[int, int]:
97 """Number of characters in data and padding."""
98 i
, k
= divmod(self
.n
, 3)
99 return 4 * i
+ (0 if k
== 0 else k
+ 1), (3 - k
) % 3
103 return sum(self
.format
)
107 data
, padding
= self
.format
108 return f
"[{self.alphabet}]{{{data}}}={{{padding}}}"
111 from base64
import b64decode
113 return b64decode(s
, validate
= True)
116 _HASHES
= (hashlib
.new(n
) for n
in ("SHA-256", "SHA-512"))
117 ENCODINGS
= {h
.name
: Encoding
.all(h
) for h
in _HASHES
}
121 (f
"({h}-)?" if e
.name
== "base64" else "") + f
"(?P<{h}_{e.name}>{e.regex})"
124 for h
, encodings
in ENCODINGS
.items()
127 _DEF_RE
= re
.compile(
129 f
"(?P<{h}>{h} = (?P<{h}_quote>['\"])({re})(?P={h}_quote);)"
130 for h
, re
in RE
.items()
135 def defToSRI(s
: str) -> str:
136 def f(m
: re
.Match
[str]) -> str:
138 for h
, encodings
in ENCODINGS
.items():
139 if m
.group(h
) is None:
143 s
= m
.group(f
"{h}_{e.name}")
145 return f
'hash = "{e.toSRI(s)}";'
147 raise ValueError(f
"Match with '{h}' but no subgroup")
148 raise ValueError("Match with no hash")
150 except ValueError as exn
:
157 return _DEF_RE
.sub(f
, s
)
161 def atomicFileUpdate(target
: Path
):
162 """Atomically replace the contents of a file.
164 Guarantees that no temporary files are left behind, and `target` is either
165 left untouched, or overwritten with new content if no exception was raised.
167 Yields a pair `(original, new)` of open files.
168 `original` is the pre-existing file at `target`, open for reading;
169 `new` is an empty, temporary file in the same filder, open for writing.
171 Upon exiting the context, the files are closed; if no exception was
172 raised, `new` (atomically) replaces the `target`, otherwise it is deleted.
174 # That's mostly copied from noto-emoji.py, should DRY it out
175 from tempfile
import NamedTemporaryFile
178 with target
.open() as original
:
179 with
NamedTemporaryFile(
181 prefix
= target
.stem
,
182 suffix
= target
.suffix
,
184 mode
="w", # otherwise the file would be opened in binary mode by default
186 tmpPath
= Path(new
.name
)
187 yield (original
, new
)
189 tmpPath
.replace(target
)
192 tmpPath
.unlink(missing_ok
= True)
196 def fileToSRI(p
: Path
):
197 with
atomicFileUpdate(p
) as (og
, new
):
198 for i
, line
in enumerate(og
):
199 with
log_context(line
= i
):
200 new
.write(defToSRI(line
))
203 _SKIP_RE
= re
.compile("(generated by)|(do not edit)", re
.IGNORECASE
)
204 _IGNORE
= frozenset({
209 if __name__
== "__main__":
212 logger
.info("Starting!")
214 def handleFile(p
: Path
, skipLevel
= logging
.INFO
):
215 with
log_context(file = str(p
)):
222 if _SKIP_RE
.search(line
):
223 logger
.log(skipLevel
, "File looks autogenerated, skipping!")
228 except Exception as exn
:
230 "Unhandled exception, skipping file!",
234 logger
.info("Finished processing file")
238 with
log_context(arg
= arg
):
240 handleFile(p
, skipLevel
= logging
.WARNING
)
243 logger
.info("Recursing into directory")
244 for q
in p
.glob("**/*.nix"):
246 if q
.name
in _IGNORE
or q
.name
.find("generated") != -1:
247 logger
.info("File looks autogenerated, skipping!")