13 import xml
.dom
.minidom
24 Digest16
= NewType('Digest16', str)
25 Digest32
= NewType('Digest32', str)
28 class InfoTableEntry(types
.SimpleNamespace
):
35 class Info(types
.SimpleNamespace
):
40 table
: Dict
[str, InfoTableEntry
]
44 class VerificationError(Exception):
50 def __init__(self
) -> None:
53 def status(self
, s
: str) -> None:
54 print(s
, end
=' ', flush
=True)
55 self
.line_length
+= 1 + len(s
) # Unicode??
58 def _color(s
: str, c
: int) -> str:
59 return '\033[%2dm%s\033[00m' % (c
, s
)
61 def result(self
, r
: bool) -> None:
62 message
, color
= {True: ('OK ', 92), False: ('FAIL', 91)}
[r
]
64 cols
= shutil
.get_terminal_size().columns
65 pad
= (cols
- (self
.line_length
+ length
)) % cols
66 print(' ' * pad
+ self
._color
(message
, color
))
69 raise VerificationError()
71 def check(self
, s
: str, r
: bool) -> None:
80 b
: str) -> Tuple
[Sequence
[str],
84 def throw(error
: OSError) -> None:
87 def join(x
: str, y
: str) -> str:
88 return y
if x
== '.' else os
.path
.join(x
, y
)
90 def recursive_files(d
: str) -> Iterable
[str]:
91 all_files
: List
[str] = []
92 for path
, dirs
, files
in os
.walk(d
, onerror
=throw
):
93 rel
= os
.path
.relpath(path
, start
=d
)
94 all_files
.extend(join(rel
, f
) for f
in files
)
95 for dir_or_link
in dirs
:
96 if os
.path
.islink(join(path
, dir_or_link
)):
97 all_files
.append(join(rel
, dir_or_link
))
100 def exclude_dot_git(files
: Iterable
[str]) -> Iterable
[str]:
101 return (f
for f
in files
if not f
.startswith('.git/'))
103 files
= functools
.reduce(
106 recursive_files(x
))) for x
in [a
, b
]))
107 return filecmp
.cmpfiles(a
, b
, files
, shallow
=False)
110 def fetch(v
: Verification
, channel_url
: str) -> Info
:
112 info
.url
= channel_url
113 v
.status('Fetching channel')
114 request
= urllib
.request
.urlopen(
115 'https://channels.nixos.org/nixos-20.03', timeout
=10)
116 info
.channel_html
= request
.read()
117 info
.forwarded_url
= request
.geturl()
118 v
.result(request
.status
== 200)
119 v
.check('Got forwarded', info
.url
!= info
.forwarded_url
)
123 def parse_channel(v
: Verification
, info
: Info
) -> None:
124 v
.status('Parsing channel description as XML')
125 d
= xml
.dom
.minidom
.parseString(info
.channel_html
)
128 v
.status('Extracting release name:')
129 title_name
= d
.getElementsByTagName(
130 'title')[0].firstChild
.nodeValue
.split()[2]
131 h1_name
= d
.getElementsByTagName('h1')[0].firstChild
.nodeValue
.split()[2]
133 v
.result(title_name
== h1_name
)
134 info
.release_name
= title_name
136 v
.status('Extracting git commit:')
137 git_commit_node
= d
.getElementsByTagName('tt')[0]
138 info
.git_commit
= git_commit_node
.firstChild
.nodeValue
139 v
.status(info
.git_commit
)
141 v
.status('Verifying git commit label')
142 v
.result(git_commit_node
.previousSibling
.nodeValue
== 'Git commit ')
144 v
.status('Parsing table')
146 for row
in d
.getElementsByTagName('tr')[1:]:
147 name
= row
.childNodes
[0].firstChild
.firstChild
.nodeValue
148 url
= row
.childNodes
[0].firstChild
.getAttribute('href')
149 size
= int(row
.childNodes
[1].firstChild
.nodeValue
)
150 digest
= Digest16(row
.childNodes
[2].firstChild
.firstChild
.nodeValue
)
151 info
.table
[name
] = InfoTableEntry(url
=url
, digest
=digest
, size
=size
)
155 def digest_file(filename
: str) -> Digest16
:
156 hasher
= hashlib
.sha256()
157 with open(filename
, 'rb') as f
:
158 # pylint: disable=cell-var-from-loop
159 for block
in iter(lambda: f
.read(4096), b
''):
161 return Digest16(hasher
.hexdigest())
164 def to_Digest16(v
: Verification
, digest32
: Digest32
) -> Digest16
:
165 v
.status('Converting digest to base16')
166 process
= subprocess
.run(
167 ['nix', 'to-base16', '--type', 'sha256', digest32
], capture_output
=True)
168 v
.result(process
.returncode
== 0)
169 return Digest16(process
.stdout
.decode().strip())
172 def to_Digest32(v
: Verification
, digest16
: Digest16
) -> Digest32
:
173 v
.status('Converting digest to base32')
174 process
= subprocess
.run(
175 ['nix', 'to-base32', '--type', 'sha256', digest16
], capture_output
=True)
176 v
.result(process
.returncode
== 0)
177 return Digest32(process
.stdout
.decode().strip())
180 def fetch_with_nix_prefetch_url(
183 digest
: Digest16
) -> str:
184 v
.status('Fetching %s' % url
)
185 process
= subprocess
.run(
186 ['nix-prefetch-url', '--print-path', url
, digest
], capture_output
=True)
187 v
.result(process
.returncode
== 0)
188 prefetch_digest
, path
, empty
= process
.stdout
.decode().split('\n')
190 v
.check("Verifying nix-prefetch-url's digest",
191 to_Digest16(v
, Digest32(prefetch_digest
)) == digest
)
192 v
.status("Verifying file digest")
193 file_digest
= digest_file(path
)
194 v
.result(file_digest
== digest
)
198 def fetch_resources(v
: Verification
, info
: Info
) -> None:
199 for resource
in ['git-revision', 'nixexprs.tar.xz']:
200 fields
= info
.table
[resource
]
201 url
= urllib
.parse
.urljoin(info
.forwarded_url
, fields
.url
)
202 fields
.file = fetch_with_nix_prefetch_url(v
, url
, fields
.digest
)
203 v
.status('Verifying git commit on main page matches git commit in table')
206 info
.table
['git-revision'].file).read(999) == info
.git_commit
)
209 def check_channel_contents(v
: Verification
, info
: Info
) -> None:
210 with tempfile
.TemporaryDirectory() as d
:
211 v
.status('Extracting %s' % info
.table
['nixexprs.tar.xz'].file)
212 shutil
.unpack_archive(info
.table
['nixexprs.tar.xz'].file, d
)
214 v
.status('Removing temporary directory')
220 info
= fetch(v
, 'https://channels.nixos.org/nixos-20.03')
221 parse_channel(v
, info
)
222 fetch_resources(v
, info
)
223 check_channel_contents(v
, info
)