Coverage for sources/emcdproj/website/inventory.py: 86%

113 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 22:01 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Publication validation: inventory, history, and anchors. ''' 

22 

23 

24from . import __ 

25from . import locations as _locations 

26from . import tracks as _tracks 

27 

28_MUTABLE_TOP_LEVEL: tuple[ str, ... ] = ( 

29 'index.html', 'versions.json', 'provenance.json', 

30 'coverage.svg', '.nojekyll' ) 

31 

32 

33_VOLATILE_VERSION_FILES: tuple[ str, ... ] = ( 

34 'coverage.xml', '.buildinfo' ) 

35 

36 

37def snapshot_archive( archive: __.Path ) -> dict[ str, str ]: 

38 ''' Snapshots retained-history digests from the trusted archive. 

39 

40 Reads the pre-update archive members without extracting, so 

41 restore loss itself is visible to later comparison. Returns an 

42 empty manifest when no archive exists yet. 

43 ''' 

44 import hashlib 

45 import tarfile 

46 if not archive.is_file( ): return { } 

47 manifest: dict[ str, str ] = { } 

48 try: 

49 with tarfile.open( archive, mode = 'r:*' ) as bundle: 

50 for member in bundle.getmembers( ): 

51 if not member.isfile( ): continue 

52 name = member.name.replace( '\\', '/' ) 

53 while name.startswith( './' ): name = name[ 2 : ] 

54 if not name or name == '.': continue 54 ↛ 50line 54 didn't jump to line 50 because the continue on line 54 wasn't executed

55 handle = bundle.extractfile( member ) 

56 if handle is None: continue 56 ↛ 50line 56 didn't jump to line 50 because the continue on line 56 wasn't executed

57 manifest[ name ] = hashlib.sha256( 

58 handle.read( ) ).hexdigest( ) 

59 except ( tarfile.TarError, OSError, EOFError ) as exception: 

60 raise __.exceptions.PublicationSourceInvalidity( 

61 str( archive ), 'unreadable archive' ) from exception 

62 return manifest 

63 

64 

65def reject_alias_collisions( 

66 index_data: dict[ __.typx.Any, __.typx.Any ], 

67 version: str, 

68) -> None: 

69 ''' Rejects aliases colliding with retained releases. 

70 

71 An alias pointing at a different retained version would overwrite 

72 history that the retained check then skips. Only deliberate 

73 identity (alias equal to its selected version) is allowed. 

74 ''' 

75 mapping = index_data.get( 'track_versions', { } ) 

76 retained = { 

77 label for label in index_data.get( 'versions', { } ) 

78 if label != version } 

79 for alias, selected in mapping.items( ): 

80 if alias != selected and alias in retained: 

81 raise __.exceptions.PublicationSourceInvalidity( 

82 alias, 'alias collides with retained release' ) 

83 if version in mapping and mapping.get( version ) != version: 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true

84 raise __.exceptions.PublicationSourceInvalidity( 

85 version, 'version collides with alias' ) 

86 

87 

88def verify_retained_history( 

89 locations: _locations.Locations, 

90 snapshot: dict[ str, str ], 

91 version: str, 

92 tracks: _tracks.TrackConfiguration, 

93) -> None: 

94 ''' Verifies retained history against the pre-build snapshot. 

95 

96 Every snapshotted file outside the published version, configured 

97 aliases, and mutable top-level files must exist with identical 

98 content. Republished versions must reproduce retained files 

99 byte-identically apart from volatile build outputs; changed 

100 same-version content is refused while idempotent reruns pass. 

101 Legitimate new version files and mutable outputs are excluded by 

102 explicit class rules, never by regeneration. 

103 ''' 

104 import hashlib 

105 aliases = { 

106 track.get( 'alias', name ) for name, track in tracks.items( ) } 

107 for relative, digest in snapshot.items( ): 

108 head = relative.split( '/', 1 )[ 0 ] 

109 if head in aliases and head != version: continue 

110 if '/' not in relative and relative in _MUTABLE_TOP_LEVEL: continue 

111 if head == version: 

112 base = relative.rsplit( '/', 1 )[ -1 ] 

113 if base in _VOLATILE_VERSION_FILES: continue 

114 candidate = locations.website / relative 

115 if not candidate.is_file( ): 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 raise __.exceptions.PublicationSourceInvalidity( relative, 

117 'history lost' ) 

118 actual = hashlib.sha256( candidate.read_bytes( ) ).hexdigest( ) 

119 if actual != digest: 

120 raise __.exceptions.PublicationSourceInvalidity( relative, 

121 'history drift' ) 

122 

123 

124def _validate_inventory_path( root: __.Path, entry: str ) -> __.Path: 

125 ''' Resolves inventory entry path under website root. ''' 

126 if entry.startswith( '/' ) or entry.startswith( '\\' ): 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 raise __.exceptions.PublicationSourceInvalidity( entry, 

128 'absolute path rejected' ) 

129 parts = entry.replace( '\\', '/' ).split( '/' ) 

130 if '..' in parts or not entry: 

131 raise __.exceptions.PublicationSourceInvalidity( entry, 

132 'traversal rejected' ) 

133 return root / entry.replace( '/', __.os.sep ) 

134 

135 

136def validate_legacy_inventory( 

137 locations: _locations.Locations, inventory: __.Path | str 

138) -> None: 

139 ''' Validates legacy-link inventory against built tree. 

140 

141 Every entry must resolve; frozen entries require matching content 

142 digests; listed fragments must occur as element IDs. Malformed 

143 staged input aborts promotion without changing prior output. 

144 ''' 

145 path = __.Path( inventory ) 

146 try: content = path.read_bytes( ) 

147 except OSError as exception: 

148 raise __.exceptions.FileAwol( path ) from exception 

149 try: data = _tracks.toml_loads( content ) 

150 except Exception as exception: 

151 raise __.exceptions.PublicationSourceInvalidity( 

152 str( path ), 'unparseable inventory' ) from exception 

153 try: items = list( data.get( 'entries', [ ] ) ) 

154 except TypeError as exception: 

155 raise __.exceptions.PublicationSourceInvalidity( 

156 str( path ), 'malformed inventory' ) from exception 

157 if not items: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 raise __.exceptions.PublicationSourceInvalidity( 

159 str( path ), 'empty inventory' ) 

160 for raw_entry in items: 

161 _validate_inventory_entry( locations, raw_entry ) 

162 

163 

164def _validate_inventory_entry( 

165 locations: _locations.Locations, raw_entry: __.typx.Any 

166) -> None: 

167 ''' Validates one legacy inventory entry against built tree. 

168 

169 Consumes dynamic TOML values validated at runtime via duck typing; 

170 malformed staged input aborts promotion. 

171 ''' 

172 get = getattr( raw_entry, 'get', None ) 

173 if get is None: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 raise __.exceptions.PublicationSourceInvalidity( 'entry', 

175 'malformed entry' ) 

176 relative = get( 'path', '' ) 

177 semantics = get( 'semantics', 'frozen' ) 

178 if not isinstance( relative, str ) or not isinstance( semantics, str ): 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true

179 raise __.exceptions.PublicationSourceInvalidity( 'entry', 

180 'malformed entry' ) 

181 if semantics not in ( 'frozen', 'follow' ): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 raise __.exceptions.PublicationSourceInvalidity( relative, 

183 'unknown semantics' ) 

184 target = _validate_inventory_path( locations.website, relative ) 

185 if not target.is_file( ): 

186 raise __.exceptions.PublicationSourceInvalidity( relative, 

187 'entry absent' ) 

188 _check_entry_digest( 

189 target, relative, get( 'sha256', '' ), semantics ) 

190 _check_entry_fragments( 

191 target, relative, get( 'fragments', [ ] ) ) 

192 

193 

194def _check_entry_digest( 

195 target: __.Path, relative: str, digest: str, semantics: str 

196) -> None: 

197 ''' Checks entry content digest for frozen or pinned entries. ''' 

198 import hashlib 

199 if digest or semantics == 'frozen': 

200 if not digest: 

201 raise __.exceptions.PublicationSourceInvalidity( 

202 relative, 'frozen entry without digest' ) 

203 actual = hashlib.sha256( target.read_bytes( ) ).hexdigest( ) 

204 if actual != digest: 

205 raise __.exceptions.PublicationSourceInvalidity( 

206 relative, 'content drift detected' ) 

207 

208 

209def _check_entry_fragments( 

210 target: __.Path, relative: str, fragment_source: __.typx.Any 

211) -> None: 

212 ''' Checks entry anchor fragments as parsed element IDs. ''' 

213 if isinstance( fragment_source, str ): 

214 raise __.exceptions.PublicationSourceInvalidity( 

215 relative, 'malformed fragments' ) 

216 try: fragments = list( fragment_source ) 

217 except TypeError as exception: 

218 raise __.exceptions.PublicationSourceInvalidity( 

219 relative, 'malformed fragments' ) from exception 

220 identifiers = extract_element_ids( 

221 target.read_text( errors = 'replace' ) ) 

222 for fragment in fragments: 

223 if not isinstance( fragment, str ) or fragment not in identifiers: 

224 raise __.exceptions.PublicationSourceInvalidity( relative, 

225 'fragment absent' ) 

226 

227 

228def extract_element_ids( content: str ) -> set[ str ]: 

229 ''' Extracts element IDs from HTML content with a real parser. ''' 

230 import html.parser 

231 identifiers: set[ str ] = set( ) 

232 

233 class Collector( html.parser.HTMLParser ): 

234 

235 def handle_starttag( 

236 self, tag: str, attrs: list[ tuple[ str, str | None ] ] 

237 ) -> None: 

238 for name, value in attrs: 

239 if name == 'id' and value: 

240 identifiers.add( value ) 

241 

242 parser = Collector( convert_charrefs = True ) 

243 parser.feed( content ) 

244 return identifiers