Coverage for sources/mimeogram/fsprotect/cache.py: 87%

97 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-22 03:16 +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''' Cache for filesystem protection checks. ''' 

22 

23 

24from __future__ import annotations 

25 

26from . import __ 

27from . import core as _core 

28 

29 

30_scribe = __.produce_scribe( __name__ ) 

31 

32 

33class Rule( 

34 metaclass = __.ImmutableStandardDataclass, 

35 decorators = ( __.standard_dataclass, ), 

36): 

37 ''' Rule for path protection. ''' 

38 

39 paths: frozenset[ __.Path ] 

40 patterns: frozenset[ str ] = frozenset( ) 

41 

42 

43class Cache( _core.Protector, decorators = ( __.standard_dataclass, ) ): 

44 ''' Cache of protected paths and patterns for platform. ''' 

45 

46 rules: dict[ _core.Reasons, Rule ] 

47 defaults_disablement: frozenset[ str ] 

48 rules_supercession: __.ImmutableDictionary[ 

49 __.Path, tuple[ frozenset[ str ], frozenset[ str ] ] ] 

50 

51 @classmethod 

52 def from_configuration( selfclass, auxdata: __.Globals ) -> Cache: 

53 ''' Initializes protection cache for current platform. ''' 

54 _scribe.debug( 'Initializing protection cache.' ) 

55 rules: dict[ _core.Reasons, Rule ] = { } 

56 discover_platform_locations( auxdata, rules ) 

57 provide_credentials_locations( rules ) 

58 provide_project_locations( rules ) 

59 disables, supercedes = _process_configuration( auxdata, rules ) 

60 return selfclass( 

61 rules = rules, 

62 defaults_disablement = disables, 

63 rules_supercession = supercedes ) 

64 

65 def verify( self, path: __.Path ) -> _core.Status: # pylint: disable=too-many-locals 

66 ''' Verifies if a path should be protected using cached data. ''' 

67 path = _normalize_path( path ) 

68 _scribe.debug( f"Path: {path}" ) 

69 

70 if any( part in self.defaults_disablement for part in path.parts ): 

71 return _core.Status( path = path, active = False ) 

72 

73 for dir_path, ( ignore, protect ) in self.rules_supercession.items( ): 

74 dir_path_ = _normalize_path( dir_path ) 

75 if not path.is_relative_to( dir_path_ ): continue 

76 rel_path = path.relative_to( dir_path_ ) 

77 if _check_path_patterns( rel_path, ignore ): 

78 return _core.Status( path = path, active = False ) 

79 if _check_path_patterns( rel_path, protect ): 

80 return _core.Status( 

81 path = path, 

82 reason = _core.Reasons.PlatformSensitive, 

83 active = True ) 

84 

85 for reason, rule in self.rules.items( ): 

86 for protected_path in rule.paths: 

87 protected_path_ = _normalize_path( protected_path ) 

88 if path.is_relative_to( protected_path_ ): 

89 return _core.Status( 

90 path = path, 

91 reason = reason, 

92 active = True ) 

93 if _check_path_patterns( path, rule.patterns ): 

94 return _core.Status( 

95 path = path, 

96 reason = reason, 

97 active = True ) 

98 

99 return _core.Status( path = path, active = False ) 

100 

101 

102def provide_credentials_locations( 

103 rules: dict[ _core.Reasons, Rule ] 

104) -> None: 

105 ''' Provides common locations for credentials and other secrets. ''' 

106 from . import home as module 

107 home = __.Path.home( ) 

108 cred_paths = { 

109 home / path for path in module.discover_sensitive_locations( ) } 

110 rules[ _core.Reasons.Credentials ] = Rule( 

111 paths = frozenset( cred_paths ) ) 

112 

113 

114def provide_project_locations( 

115 rules: dict[ _core.Reasons, Rule ] 

116) -> None: 

117 ''' Provides IDE and VCS locations relative to project. ''' 

118 from . import project as module 

119 project_sensitive = module.discover_sensitive_locations( ) 

120 # TODO: Consider whether these patterns are compatible with Windows. 

121 rules[ _core.Reasons.Concealment ] = Rule( 

122 paths = frozenset( ), 

123 patterns = frozenset( 

124 f"**/{path}/**" for path in project_sensitive ) ) 

125 

126 

127def _check_path_patterns( path: __.Path, patterns: frozenset[ str ] ) -> bool: 

128 ''' Checks if path matches any of the glob patterns. ''' 

129 from wcmatch import glob 

130 str_path = str( path ) 

131 return glob.globmatch( str_path, list( patterns ), flags = glob.GLOBSTAR ) 

132 

133 

134def discover_platform_locations( 

135 auxdata: __.Globals, rules: dict[ _core.Reasons, Rule ] 

136) -> None: 

137 ''' Discovers system and user locations based on platform. ''' 

138 match __.sys.platform: 

139 case 'darwin': 139 ↛ 140line 139 didn't jump to line 140 because the pattern on line 139 never matched

140 from . import macos as module 

141 sys_paths = module.discover_system_paths( ) 

142 user_paths = module.discover_user_paths( ) 

143 case 'win32': 143 ↛ 144line 143 didn't jump to line 144 because the pattern on line 143 never matched

144 from . import windows as module 

145 sys_paths = module.discover_system_paths( ) 

146 user_paths = module.discover_user_paths( ) 

147 case _: 

148 from . import unix as module 

149 sys_paths = module.discover_system_paths( ) 

150 user_paths: frozenset[ __.Path ] = frozenset( ) 

151 rules[ _core.Reasons.OsDirectory ] = Rule( paths = frozenset( sys_paths ) ) 

152 config_paths = ( 

153 user_paths | { __.Path( auxdata.directories.user_config_path ), } ) 

154 rules[ _core.Reasons.UserConfiguration ] = Rule( 

155 paths = frozenset( config_paths ) ) 

156 

157 

158def _expand_location( path: str ) -> __.Path: 

159 ''' Expands path with home directory and environment variables. ''' 

160 expanded = __.os.path.expanduser( __.os.path.expandvars( path ) ) 

161 if ( __.sys.platform == 'win32' # pylint: disable=magic-value-comparison 161 ↛ 165line 161 didn't jump to line 165 because the condition on line 161 was never true

162 and expanded.startswith( '/' ) 

163 and not expanded.startswith( '//' ) # Skip UNC paths 

164 ): 

165 expanded = expanded.lstrip( '/' ) 

166 path_obj = __.Path( expanded ) 

167 if not path_obj.is_absolute( ): 

168 path_obj = __.Path( __.Path.cwd( ).drive + '/' + expanded ) 

169 else: path_obj = __.Path( expanded ) 

170 return _normalize_path( path_obj.resolve( ) ) 

171 

172 

173def _normalize_path( path: __.Path ) -> __.Path: 

174 ''' Normalizes path for consistent comparison across platforms. ''' 

175 resolved = path.resolve( ) 

176 if __.sys.platform == 'win32' and resolved.drive: # pylint: disable=magic-value-comparison 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 return __.Path( 

178 resolved.drive.lower( ) 

179 + str( resolved )[ len( resolved.drive ): ] ) 

180 return resolved 

181 

182 

183def _process_configuration( 

184 auxdata: __.Globals, 

185 rules: dict[ _core.Reasons, Rule ], 

186) -> tuple[ 

187 frozenset[ str ], 

188 __.ImmutableDictionary[ 

189 __.Path, tuple[ frozenset[ str ], frozenset[ str ] ] ], 

190]: 

191 config = auxdata.configuration.get( 'protection', { } ) 

192 if not config: return frozenset( ), __.ImmutableDictionary( ) 

193 # Additional locations and patterns. 

194 locations_add = { 

195 _expand_location( path ) 

196 for path in config.get( 'additional-locations', ( ) ) } 

197 patterns_add = set( config.get( 'additional-patterns', ( ) ) ) 

198 rules[ _core.Reasons.CustomAddition ] = Rule( 

199 paths = frozenset( locations_add ), 

200 patterns = frozenset( patterns_add ) ) 

201 # Override defaults. 

202 patterns_remove = frozenset( config.get( 'defaults-disablement', ( ) ) ) 

203 # Process directory overrides 

204 supercession_rules: dict[ 

205 __.Path, tuple[ frozenset[ str ], frozenset[ str ] ] ] = { } 

206 for dir_path, dir_rules in config.get( 

207 'rules-supercession', { } 

208 ).items( ): 

209 full_path = _expand_location( dir_path ) 

210 supercession_rules[ full_path ] = ( 

211 frozenset( dir_rules.get( 'ignore', [ ] ) ), 

212 frozenset( dir_rules.get( 'protect', [ ] ) ) ) 

213 return patterns_remove, __.ImmutableDictionary( supercession_rules )