├── addons └── safe_resource_loader │ ├── plugin.cfg │ ├── plugin.gd │ ├── resource_lexer.gd │ ├── resource_parser.gd │ └── safe_resource_loader.gd └── safe_resource_loader_example ├── example.gd ├── example.tscn ├── saved_game.gd ├── saved_game_subresource.gd └── test_resources ├── .gdignore ├── README.md ├── another_unsafe_resource.tres ├── my_script.gd ├── safe_resource.tres ├── unsafe_resource.tres └── yet_another_unsafe_resource.tres /addons/safe_resource_loader/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="Safe Resource Loader" 4 | description="An extension for safely loading resource files from unknown sources." 5 | author="Jan Thomä" 6 | version="0.2.3" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /addons/safe_resource_loader/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | -------------------------------------------------------------------------------- /addons/safe_resource_loader/resource_lexer.gd: -------------------------------------------------------------------------------- 1 | ## This is a lexer for reading godot resource files. It returns a list of tokens. 2 | ## This can then be used to parse the resource file and check for any unsafe content. 3 | ## Note that this is a simplified version which is sufficient to check the things 4 | ## we need for our plugin (mainly tags and their attributes) but simplifies things 5 | ## like expressions into an "OTHER" token. As such it's not a general purpose 6 | ## resource file lexer. 7 | 8 | # Token types 9 | enum TokenType { 10 | COMMENT, 11 | STRING, 12 | STRING_NAME, 13 | WHITESPACE, 14 | NEWLINE, # unicode 10, everything else is whitespace 15 | OPEN_CURLY, 16 | CLOSE_CURLY, 17 | OPEN_BRACKET, 18 | CLOSE_BRACKET, 19 | OPEN_PAREN, 20 | CLOSE_PAREN, 21 | COLON, 22 | ASSIGN, 23 | COMMA, 24 | OTHER # anything that is not the above. for our purposes we don't need more 25 | } 26 | 27 | 28 | var _text: String = "" 29 | var _length: int = 0 30 | var _offset: int = 0 31 | var _tokens: Array[Token] = [] 32 | var _line:int = 1 33 | 34 | var _encountered_error:bool = false 35 | ## Whether the lexer encountered an error durign lexing. 36 | var encountered_error:bool: 37 | get: return _encountered_error 38 | 39 | func tokens(text: String) -> Array[Token]: 40 | _text = text 41 | _length = text.length() 42 | _offset = 0 43 | _line = 1 44 | _encountered_error = false 45 | 46 | while _offset < _length: 47 | var token: Token = _next_token() 48 | if token: 49 | _tokens.append(token) 50 | else: 51 | push_error("Encountered unknown token at line %s (file offset %)" % [_line, _offset]) 52 | _encountered_error = true 53 | return [] 54 | 55 | return _tokens 56 | 57 | func _current_char() -> int: 58 | return _text.unicode_at(_offset) 59 | 60 | func _next_token() -> Token: 61 | # calling _current_char is somewhat expensive, so we cache it 62 | var c: int = _current_char() 63 | 64 | # check for comments (starting with ; and ending with \n) 65 | if c == 59: # 59 is unicode for semicolon 66 | return _read_comment() 67 | 68 | # check for newline 69 | if c == 10: # 10 is unicode for newline 70 | _line += 1 71 | _offset += 1 72 | return Token.new(TokenType.NEWLINE, "\\n", _offset - 1, _offset, _line - 1) 73 | 74 | # check for whitespace 75 | if _is_whitespace(c): 76 | return _read_whitespace() 77 | 78 | # check for strings (starting with " and ending with ") 79 | if c == 34: # 34 is unicode for double quote 80 | return _read_string() 81 | 82 | # check for string names (starting with @ or & followed by " and ending with ") 83 | if c == 64 or c == 38: # 64 is unicode for @, 38 is unicode for & 84 | # check if the next character is a double quote 85 | if _offset + 1 < _length and _text.unicode_at(_offset + 1) == 34: # 34 is unicode for double quote 86 | _offset += 1 # skip the @ or & 87 | return _read_string_name() 88 | 89 | # check for curly braces 90 | if c == 123: # 123 is unicode for { 91 | _offset += 1 92 | return Token.new(TokenType.OPEN_CURLY, "{", _offset - 1, _offset, _line) 93 | 94 | if c == 125: # 125 is unicode for } 95 | _offset += 1 96 | return Token.new(TokenType.CLOSE_CURLY, "}", _offset - 1, _offset, _line) 97 | 98 | # check for brackets 99 | if c == 91: # 91 is unicode for [ 100 | _offset += 1 101 | return Token.new(TokenType.OPEN_BRACKET, "[", _offset - 1, _offset, _line) 102 | 103 | if c == 93: # 93 is unicode for ] 104 | _offset += 1 105 | return Token.new(TokenType.CLOSE_BRACKET, "]", _offset - 1, _offset, _line) 106 | 107 | # check for parentheses 108 | if c == 40: # 40 is unicode for ( 109 | _offset += 1 110 | return Token.new(TokenType.OPEN_PAREN, "(", _offset - 1, _offset, _line) 111 | 112 | if c == 41: # 41 is unicode for ) 113 | _offset += 1 114 | return Token.new(TokenType.CLOSE_PAREN, ")", _offset - 1, _offset, _line) 115 | 116 | # check for colon 117 | if c == 58: # 58 is unicode for : 118 | _offset += 1 119 | return Token.new(TokenType.COLON, ":", _offset - 1, _offset, _line) 120 | 121 | # check for assignment 122 | if c == 61: # 61 is unicode for = 123 | _offset += 1 124 | return Token.new(TokenType.ASSIGN, "=", _offset - 1, _offset, _line) 125 | 126 | # check for comma 127 | if c == 44: # 44 is unicode for , 128 | _offset += 1 129 | return Token.new(TokenType.COMMA, ",", _offset - 1, _offset, _line) 130 | 131 | # anything else is an "other" token (doesn't match any of the above). 132 | # however we don't want to return a token for every single character, so we 133 | # we collect all of the characters until we find one of the above tokens. 134 | var start: int = _offset 135 | while _offset < _length: 136 | c = _current_char() 137 | # if the character is any of the preceding stuff, we are done 138 | if c == 59 or _is_whitespace(c) or c == 10 or c == 34 or c == 91 or c == 93 or c == 40 or c == 41 or c == 58 or c == 61 or c == 44: 139 | break 140 | # special case for string names, we want to stop if we see a @ or & followed by a " 141 | if c == 64 or c == 38: 142 | if _offset + 1 < _length and _text.unicode_at(_offset + 1) == 34: # 34 is unicode for double quote 143 | break 144 | _offset += 1 145 | 146 | var end: int = _offset 147 | var value: String = _text.substr(start, end - start) 148 | # if the value is empty, we don't want to return a token 149 | if value == "": 150 | # should never happen 151 | return null 152 | 153 | return Token.new(TokenType.OTHER, value, start, end, _line) 154 | 155 | 156 | 157 | 158 | func _read_comment() -> Token: 159 | var start: int = _offset 160 | while _offset < _length and _current_char() != 10: # 10 is unicode for newline 161 | _offset += 1 162 | 163 | var end: int = _offset - 1 164 | return Token.new(TokenType.COMMENT, _text.substr(start, end - start), start, end, _line) 165 | 166 | func _is_whitespace(c: int) -> bool: 167 | return c == 32 or c == 9 or c == 13 # space, tab, carriage return 168 | 169 | func _read_whitespace() -> Token: 170 | var start: int = _offset 171 | while _offset < _length and _is_whitespace(_current_char()): 172 | _offset += 1 173 | var end: int = _offset 174 | return Token.new(TokenType.WHITESPACE, _text.substr(start, end - start), start, end, _line) 175 | 176 | func _read_string() -> Token: 177 | var start: int = _offset 178 | _offset += 1 # skip the opening quote 179 | while _offset < _length and _current_char() != 34: # 34 is unicode for double quote 180 | if _current_char() == 92: # 92 is unicode for backslash 181 | _offset += 1 # skip the backslash (and the next character) 182 | _offset += 1 183 | var end: int = _offset 184 | _offset += 1 # skip the closing quote 185 | return Token.new(TokenType.STRING, _text.substr(start+1, end - start -1), start, end, _line) 186 | 187 | 188 | func _read_string_name() -> Token: 189 | var start: int = _offset 190 | _offset += 1 # skip the opening @ or & 191 | while _offset < _length and _current_char() != 34: # 34 is unicode for double quote 192 | if _current_char() == 92: # 92 is unicode for backslash 193 | _offset += 1 # skip the backslash (and the next character) 194 | _offset += 1 195 | var end: int = _offset 196 | _offset += 1 # skip the closing quote 197 | return Token.new(TokenType.STRING_NAME, _text.substr(start, end - start), start, end, _line) 198 | 199 | class Token: 200 | var type: TokenType 201 | var type_as_string:String: 202 | get: 203 | match type: 204 | TokenType.COMMENT: return "COMMENT" 205 | TokenType.STRING: return "STRING" 206 | TokenType.STRING_NAME: return "STRING_NAME" 207 | TokenType.WHITESPACE: return "WHITESPACE" 208 | TokenType.NEWLINE: return "NEWLINE" 209 | TokenType.OPEN_CURLY: return "OPEN_CURLY" 210 | TokenType.CLOSE_CURLY: return "CLOSE_CURLY" 211 | TokenType.OPEN_BRACKET: return "OPEN_BRACKET" 212 | TokenType.CLOSE_BRACKET: return "CLOSE_BRACKET" 213 | TokenType.OPEN_PAREN: return "OPEN_PAREN" 214 | TokenType.CLOSE_PAREN: return "CLOSE_PAREN" 215 | TokenType.COLON: return "COLON" 216 | TokenType.ASSIGN: return "ASSIGN" 217 | TokenType.COMMA: return "COMMA" 218 | TokenType.OTHER: return "OTHER" 219 | return "UNKNOWN" 220 | 221 | var value: String 222 | var start: int 223 | var end: int 224 | var line: int 225 | 226 | func _init(type: TokenType, value: String, start: int, end: int, line:int): 227 | self.type = type 228 | self.value = value 229 | self.start = start 230 | self.end = end 231 | self.line = line 232 | 233 | 234 | 235 | func _to_string() -> String: 236 | return "%s: %s (%d, %d) @ %s" % [type_as_string, value, start, end, line] 237 | 238 | -------------------------------------------------------------------------------- /addons/safe_resource_loader/resource_parser.gd: -------------------------------------------------------------------------------- 1 | ## This parser takes tokens from the resource lexer and returns a list of tags inside the resource file. 2 | ## It skips over stuff that isn't relevant for the use case of this addon, so you cannot get the 3 | ## properties and their assigned values. 4 | 5 | const Lexer = preload("resource_lexer.gd") 6 | 7 | var _encountered_error: bool = false 8 | ## Whether the parser encountered an error durign parsing. 9 | var encountered_error: bool: 10 | get: return _encountered_error 11 | 12 | func parse(tokens: Array[Lexer.Token]) -> Array[Tag]: 13 | # strip any whitespace, newlines and comments 14 | var stripped_tokens: Array[Lexer.Token] = [] 15 | for token in tokens: 16 | match token.type: 17 | Lexer.TokenType.WHITESPACE: 18 | continue 19 | Lexer.TokenType.NEWLINE: 20 | continue 21 | Lexer.TokenType.COMMENT: 22 | continue 23 | _: 24 | stripped_tokens.append(token) 25 | 26 | var context: MatchContext = MatchContext.new(stripped_tokens) 27 | var result: Array[Tag] = [] 28 | 29 | while true: 30 | if context.is_end_of_file(): 31 | break 32 | result.append(_tag(context)) 33 | if _encountered_error: 34 | return [] 35 | 36 | return result 37 | 38 | func _tag(context:MatchContext) -> Tag: 39 | if not _sequence([_open_bracket(), _other("name")]).match_item(context): 40 | _report_unexpected_token(context) 41 | return null 42 | 43 | var tag: Tag = Tag.new() 44 | tag.name = context.retrieve("name") 45 | tag.line = context.retrieve_line("name") 46 | 47 | # now the attributes 48 | while _cache("attribute", func(): return _sequence( [_other("name"), _assign(), _value("value")])).match_item(context): 49 | var attribute: Attribute = Attribute.new() 50 | attribute.name = context.retrieve("name") 51 | attribute.value = context.retrieve("value") 52 | tag.attributes.append(attribute) 53 | 54 | # closing bracket 55 | if not _close_bracket().match_item(context): 56 | _report_unexpected_token(context) 57 | return null 58 | 59 | # finally any amount of key_value pairs, if they exist 60 | _cache("key_value_pairs", func(): return _repeat(_sequence([_other(), _assign(), _value()]))).match_item(context) 61 | 62 | return tag 63 | 64 | #-- Non-terminal symbols 65 | func _dictionary_declaration() -> MatchItem: 66 | # e.g. Dictionary[String, int]({ "foo": 17, "bar": 5 }) 67 | return _cache("dictionary_declaration", func(): return _sequence([ 68 | _other_with_value("Dictionary"), 69 | _optional( # type declaration, which is optional 70 | _sequence([_open_bracket(), _type(), _comma(), _type(), _close_bracket()]) 71 | ), 72 | _open_paren(), 73 | _optional(_dictionary_literal()), 74 | _close_paren() 75 | ])) 76 | 77 | func _dictionary_literal() -> MatchItem: 78 | # e.g. { "foo": 17, "bar": 5 }, though keys can be any value 79 | var _key_value_pair := _sequence([ _value(), _colon(), _value() ]) 80 | 81 | return _cache("dictionary_literal", func(): return _sequence([ 82 | _open_curly(), 83 | _optional(_sequence( [_key_value_pair, _optional(_repeat(_sequence([_comma(), _key_value_pair])))])), 84 | _close_curly() 85 | ])) 86 | 87 | func _array_declaration() -> MatchItem: 88 | # e.g. Array[int]([ 1, 2, 3 ]) 89 | return _cache("array_declaration", func(): return _sequence([ 90 | _other_with_value("Array"), 91 | _optional( # type declaration, which is optional 92 | _sequence([_open_bracket(), _type(), _close_bracket()]).named("type_declaration") 93 | ), 94 | _open_paren(), 95 | _optional(_array_literal()), 96 | _close_paren() 97 | ])) 98 | 99 | func _array_literal() -> MatchItem: 100 | # e.g. [ 1, 2, 3 ] 101 | return _cache("array_literal", func(): return _sequence([ 102 | _open_bracket(), 103 | _optional(_comma_separated_values()), 104 | _close_bracket() 105 | ])) 106 | 107 | func _comma_separated_values() -> MatchItem: 108 | # e.g. 1, 2, 3 109 | return _cache("csv", func(): return _sequence([ _value(), _optional(_repeat(_sequence([_comma(), _value()])))])) 110 | 111 | 112 | func _invocation() -> MatchItem: 113 | # e.g. ExtResource("1_eau8o") 114 | return _cache("invocation", func(): return _sequence([ 115 | _other(), 116 | _open_paren(), 117 | _optional(_comma_separated_values()), 118 | _close_paren() 119 | ])) 120 | 121 | func _value(target: String = "") -> MatchItem: 122 | # any valid value, including strings, numbers, identifiers, etc. 123 | return _cache("value::" + target, func(): return _one_of([ 124 | _dictionary_declaration(), 125 | _dictionary_literal(), 126 | _array_declaration(), 127 | _array_literal(), 128 | _invocation(), 129 | _string(), 130 | _string_name(), 131 | _other(), 132 | ], target) 133 | ) 134 | 135 | # This is a type declaration in a dictionary or array. 136 | func _type() -> MatchItem: 137 | return _cache("type", func(): return _one_of([ 138 | # complex things go first 139 | _invocation(), # reference to a custom script, usually an ExtResource invocation 140 | _other() # simple identifier like, int, string, etc.. 141 | ])) 142 | 143 | 144 | func _sequence(items: Array[MatchItem], target:String = "") -> MatchItem: return MatchItemSequence.new(items, target) 145 | func _one_of(items: Array[MatchItem], target:String = "") -> MatchItem: return MatchItemOneOf.new(items, target) 146 | func _optional(item: MatchItem, target:String = "") -> MatchItem: return MatchItemOptional.new(item, target) 147 | func _repeat(item: MatchItem, target:String = "") -> MatchItem: return MatchItemRepeat.new(item, target) 148 | 149 | #-- Terminal symbols 150 | func _open_bracket() -> MatchItem: return _cache("open_bracket", func(): return MatchItemToken.new(Lexer.TokenType.OPEN_BRACKET)) 151 | func _close_bracket() -> MatchItem: return _cache("close_bracket", func(): return MatchItemToken.new(Lexer.TokenType.CLOSE_BRACKET)) 152 | func _open_curly() -> MatchItem: return _cache("open_curly", func(): return MatchItemToken.new(Lexer.TokenType.OPEN_CURLY)) 153 | func _close_curly() -> MatchItem: return _cache("close_curly", func(): return MatchItemToken.new(Lexer.TokenType.CLOSE_CURLY)) 154 | func _open_paren() -> MatchItem: return _cache("open_paren", func(): return MatchItemToken.new(Lexer.TokenType.OPEN_PAREN)) 155 | func _close_paren() -> MatchItem: return _cache("close_paren", func(): return MatchItemToken.new(Lexer.TokenType.CLOSE_PAREN)) 156 | func _colon() -> MatchItem: return _cache("colon", func(): return MatchItemToken.new(Lexer.TokenType.COLON)) 157 | func _assign() -> MatchItem: return _cache("assign", func(): return MatchItemToken.new(Lexer.TokenType.ASSIGN)) 158 | func _comma() -> MatchItem: return _cache("comma", func(): return MatchItemToken.new(Lexer.TokenType.COMMA)) 159 | func _other(target:String = "") -> MatchItem: return _cache("other::" + target, func(): return MatchItemToken.new(Lexer.TokenType.OTHER, target)) 160 | func _other_with_value(value:String, target:String = "") -> MatchItem: return _cache("other::" + target + "::" + value, func(): return MatchItemToken.new(Lexer.TokenType.OTHER, target).with_value(value)) 161 | 162 | func _string(target:String = "") -> MatchItem: return _cache("string::" + target, func(): return MatchItemToken.new(Lexer.TokenType.STRING, target)) 163 | func _string_name(target:String = "") -> MatchItem: return _cache("string_name::" + target, func(): return MatchItemToken.new(Lexer.TokenType.STRING_NAME, target)) 164 | 165 | # We use an item cache to avoid creating the same MatchItem multiple times and to avoid 166 | # having infinite recursion when creating the MatchItem. 167 | var _item_cache: Dictionary = {} 168 | func _cache(key: StringName, callable:Callable) -> Variant: 169 | if not _item_cache.has(key): 170 | # put in a temporary value to avoid infinite recursion 171 | var pointer: MatchItemPointer = MatchItemPointer.new().named(key) 172 | _item_cache[key] = pointer 173 | 174 | # construct the item, this may result in a recursive call 175 | var item: MatchItem = callable.call() 176 | 177 | # inject the item into the pointer 178 | if pointer.item == null: 179 | pointer.item = item 180 | 181 | return _item_cache[key] 182 | 183 | #-- Helper functions 184 | 185 | func _report_unexpected_token(context:MatchContext) -> void: 186 | _encountered_error = true 187 | if context.is_end_of_file(): 188 | push_error("Unexpected end of file") 189 | return 190 | 191 | var token: Lexer.Token = context.next_token() 192 | push_error("Unexpected token '%s' at line %d" % [token.value, token.line]) 193 | 194 | 195 | class MatchItem: 196 | var _target: String = "" 197 | var name: String = "" 198 | 199 | func named(name:String) -> MatchItem: 200 | # Set the name of the item, used for debugging 201 | self.name = name 202 | return self 203 | 204 | func _init(target:String = ""): 205 | # Constructor for MatchItem 206 | # target is the string to match against 207 | _target = target 208 | 209 | func match_item(context:MatchContext) -> bool: 210 | var start_offset:int = context.offset 211 | var result: bool = _match(context) 212 | if result and not _target.is_empty(): 213 | context.store(_target, start_offset) 214 | 215 | # enable for debugging 216 | # if result and name != "": 217 | # print("MATCH[%s]: %s" % [name, context.get_text(start_offset)]) 218 | 219 | 220 | return result 221 | 222 | func _match(context:MatchContext) -> bool: 223 | return false 224 | 225 | class MatchItemPointer: 226 | extends MatchItem 227 | 228 | var item:MatchItem = null 229 | 230 | func _match(context:MatchContext) -> bool: 231 | if item == null: 232 | push_error("Probable bug, pointer should not point to null item.") 233 | return false 234 | return item.match_item(context) 235 | 236 | 237 | class MatchItemSequence: 238 | extends MatchItem 239 | 240 | var _sequence: Array[MatchItem] = [] 241 | 242 | func _init(sequence: Array[MatchItem], target:String = "") -> void: 243 | super(target) 244 | _sequence = sequence 245 | 246 | func _match(context:MatchContext) -> bool: 247 | context.mark() 248 | for item in _sequence: 249 | if not item.match_item(context): 250 | context.reset() 251 | return false 252 | context.keep() 253 | return true 254 | 255 | class MatchItemRepeat: 256 | extends MatchItem 257 | var _item: MatchItem = null 258 | 259 | func _init(item: MatchItem, target:String = "") -> void: 260 | super(target) 261 | _item = item 262 | 263 | func _match(context:MatchContext) -> bool: 264 | var matched: int = 0 265 | while true: 266 | context.mark() 267 | if not _item.match_item(context): 268 | context.reset() 269 | break 270 | matched += 1 271 | context.keep() 272 | 273 | return matched > 0 274 | 275 | class MatchItemOneOf: 276 | extends MatchItem 277 | var _items: Array[MatchItem] = [] 278 | 279 | func _init(items: Array[MatchItem], target:String = "") -> void: 280 | super(target) 281 | _items = items 282 | 283 | func _match(context:MatchContext) -> bool: 284 | context.mark() 285 | for item in _items: 286 | if item.match_item(context): 287 | context.keep() 288 | return true 289 | context.reset() 290 | return false 291 | 292 | class MatchItemOptional: 293 | extends MatchItem 294 | var _item: MatchItem = null 295 | 296 | func _init(item: MatchItem, target:String = "") -> void: 297 | super(target) 298 | _item = item 299 | 300 | func _match(context:MatchContext) -> bool: 301 | context.mark() 302 | if _item.match_item(context): 303 | return true 304 | context.reset() 305 | return true # optional, so always return true 306 | 307 | class MatchItemToken: 308 | extends MatchItem 309 | var _token: Lexer.TokenType = -1 310 | var _must_have_value: bool = false 311 | var _expected_value: String = "" 312 | 313 | func _init(token: Lexer.TokenType, target:String = "") -> void: 314 | super(target) 315 | _token = token 316 | 317 | func with_value(expected_value:String) -> MatchItemToken: 318 | _expected_value = expected_value 319 | _must_have_value = true 320 | return self 321 | 322 | func _match(context:MatchContext) -> bool: 323 | context.mark() 324 | var token: Lexer.Token = context.next_token() 325 | if token == null: 326 | context.reset() 327 | return false 328 | if token.type != _token: 329 | context.reset() 330 | return false 331 | if _must_have_value and token.value != _expected_value: 332 | context.reset() 333 | return false 334 | return true 335 | 336 | class MatchContext: 337 | var _tokens: Array[Lexer.Token] = [] 338 | var _offset: int = -1 339 | var offset: int: 340 | get: return _offset 341 | 342 | var _count: int = 0 343 | var _marked_offsets: Array[int] = [] 344 | var _stored:Dictionary = {} 345 | var _stored_line:Dictionary = {} 346 | 347 | func _init(tokens: Array[Lexer.Token]): 348 | _tokens = tokens 349 | _count = tokens.size() 350 | 351 | func get_text(from_offset:int) -> String: 352 | var result: String = "" 353 | for i in range(from_offset, _offset): 354 | result += _tokens[i+1].value 355 | return result 356 | 357 | func store(target:String, from_offset:int): 358 | _stored[target] = get_text(from_offset) 359 | _stored_line[target] = _tokens[from_offset+1].line 360 | 361 | func retrieve(target:String) -> String: 362 | if _stored.has(target): 363 | return _stored[target] 364 | return "" 365 | 366 | func retrieve_line(target:String) -> int: 367 | if _stored_line.has(target): 368 | return _stored_line[target] 369 | return -1 370 | 371 | func mark() -> void: 372 | _marked_offsets.append(_offset) 373 | 374 | func reset() -> void: 375 | _offset = _marked_offsets.pop_back() 376 | 377 | func keep() -> void: 378 | _marked_offsets.pop_back() 379 | 380 | func next_token() -> Lexer.Token: 381 | _offset += 1 382 | if _offset < _count: 383 | return _tokens[_offset] 384 | return null 385 | 386 | func is_end_of_file() -> bool: 387 | return _offset + 1 >= _count 388 | 389 | 390 | class Tag: 391 | var name: String = "" 392 | var attributes: Array[Attribute] = [] 393 | var line: int = 0 394 | 395 | 396 | func _to_string() -> String: 397 | var result: String = "[" + name 398 | if attributes.size() > 0: 399 | result += " " 400 | for i in attributes.size(): 401 | var attribute := attributes[i] 402 | result += attribute.to_string() 403 | if i + 1 < attributes.size(): 404 | result += " " 405 | result += "]\n" 406 | return result 407 | 408 | 409 | class Attribute: 410 | var name: String = "" 411 | var value: String = "" 412 | 413 | func _to_string(): 414 | return name + "=" + value 415 | -------------------------------------------------------------------------------- /addons/safe_resource_loader/safe_resource_loader.gd: -------------------------------------------------------------------------------- 1 | class_name SafeResourceLoader 2 | 3 | const Lexer = preload("resource_lexer.gd") 4 | const Parser = preload("resource_parser.gd") 5 | 6 | ## Safely loads resource files (.tres) by scanning them for any 7 | ## embedded GDScript resources. If such resources are found, the 8 | ## loading will be aborted so embedded scripts cannote be executed. 9 | ## If loading fails for any reason, an error message will be printed 10 | ## and this function returns null. 11 | static func load(path:String, type_hint:String = "", \ 12 | cache_mode:ResourceLoader.CacheMode = ResourceLoader.CacheMode.CACHE_MODE_REUSE) -> Resource: 13 | 14 | # We only really support .tres files, so refuse to load anything else. 15 | if not path.ends_with(".tres"): 16 | push_error("This resource loader only supports .tres files.") 17 | return null 18 | 19 | # Also refuse to load anything within res:// as it should be safe and also 20 | # will not be readable after export anyways. 21 | if path.begins_with("res://"): 22 | push_error("This resource loader is intended for loading resources from unsafe " + \ 23 | "origins (e.g. saved games downloaded from the internet). Using it on safe resources " + \ 24 | "inside res:// will just slow down loading for no benefit. In addition it will not work " + \ 25 | "on exported games as resources are packed and no longer readable from the file system.") 26 | return null 27 | 28 | # Check if the file exists. 29 | if not FileAccess.file_exists(path): 30 | push_error("Cannot load resource '" + path + "' because it does not exist or is not accessible.") 31 | return null 32 | 33 | # Load it as text content, only. This will not execute any scripts. 34 | var file = FileAccess.open(path, FileAccess.READ) 35 | var file_as_text = file.get_as_text() 36 | file.close() 37 | 38 | # Step 1: convert text file into an array of tokens 39 | var lexer:Lexer = Lexer.new() 40 | var tokens:Array[Lexer.Token] = lexer.tokens(file_as_text) 41 | if lexer.encountered_error: 42 | push_error("Error during lexing. If you think this file should work, please report this as a bug and include a copy of %s so this can be fixed." % path) 43 | return null 44 | 45 | # Step 2: parse tokens into tags 46 | var parser:Parser = Parser.new() 47 | var tags:Array[Parser.Tag] = parser.parse(tokens) 48 | if parser.encountered_error: 49 | push_error("Error during parsing. If you think this file should work, please report this as a bug and include a copy of %s so this can be fixed." % path) 50 | return null 51 | 52 | for tag in tags: 53 | # find any resource which has a type of "GDScript" 54 | if tag.attributes.any(func(it): return it.name == "type" and it.value == "GDScript"): 55 | push_warning("Resource '%s' contains inline GDScript in or around line %s." % [path, tag.line]) 56 | return null 57 | 58 | # find any ext_resource which has a path outside of res:// 59 | if tag.name != "ext_resource": 60 | continue 61 | 62 | for attribute in tag.attributes: 63 | if attribute.name == "path" and not attribute.value.begins_with("res://"): 64 | push_warning(("Resource '%s'\ncontains an ext_resource with a path " + \ 65 | "outside 'res://' ('%s') in or around line %s.") % [path, attribute.value, tag.line ]) 66 | return null 67 | 68 | # otherwise use the normal resource loader to load it. 69 | return ResourceLoader.load(path, type_hint, cache_mode) 70 | -------------------------------------------------------------------------------- /safe_resource_loader_example/example.gd: -------------------------------------------------------------------------------- 1 | extends CenterContainer 2 | 3 | @onready var _result_label:Label = %ResultLabel 4 | @onready var _file_dialog:FileDialog = %FileDialog 5 | 6 | var _load_safely:bool = false 7 | 8 | 9 | func _on_open_unsafe_button_pressed(): 10 | _load_safely = false 11 | _on_open_button_pressed() 12 | 13 | 14 | func _on_open_safe_button_pressed(): 15 | _load_safely = true 16 | _on_open_button_pressed() 17 | 18 | 19 | func _on_open_button_pressed(): 20 | _file_dialog.current_dir = ProjectSettings.globalize_path("res://safe_resource_loader_example/test_resources") 21 | _file_dialog.popup_centered() 22 | 23 | 24 | func _on_file_dialog_file_selected(path): 25 | # Load the file through the safe or unsafe resource loader, depending on which button was pressed 26 | var loaded:Resource = null 27 | if _load_safely: 28 | loaded = SafeResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) 29 | else: 30 | loaded = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) 31 | 32 | 33 | if loaded == null: 34 | _result_label.text = "Resource was not loaded." 35 | else: 36 | _result_label.text = "Resource was loaded." 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /safe_resource_loader_example/example.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://dg7u2024skyge"] 2 | 3 | [ext_resource type="Script" path="res://safe_resource_loader_example/example.gd" id="1_wf41y"] 4 | 5 | [node name="Example" type="CenterContainer"] 6 | anchors_preset = 15 7 | anchor_right = 1.0 8 | anchor_bottom = 1.0 9 | grow_horizontal = 2 10 | grow_vertical = 2 11 | script = ExtResource("1_wf41y") 12 | 13 | [node name="VBoxContainer" type="VBoxContainer" parent="."] 14 | layout_mode = 2 15 | theme_override_constants/separation = 15 16 | 17 | [node name="Label" type="Label" parent="VBoxContainer"] 18 | custom_minimum_size = Vector2(500, 0) 19 | layout_mode = 2 20 | text = "This example shows how to use the SafeResourceLoader. Use the \"Loade Resource Safely\" button to select one of the provided resource files and load it with the safe resource loader. This should prevent code from the resource to be executed. Using the \"Load Resource Normal\" button will use Godot's built-in ResourceLoader and will execute code embedded in the resource." 21 | autowrap_mode = 2 22 | 23 | [node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] 24 | layout_mode = 2 25 | alignment = 1 26 | 27 | [node name="OpenSafeButton" type="Button" parent="VBoxContainer/HBoxContainer"] 28 | layout_mode = 2 29 | text = "Load Resource Safely" 30 | 31 | [node name="OpenUnsafeButton" type="Button" parent="VBoxContainer/HBoxContainer"] 32 | layout_mode = 2 33 | text = "Load Resource Normal" 34 | 35 | [node name="MarginContainer" type="MarginContainer" parent="VBoxContainer"] 36 | layout_mode = 2 37 | theme_override_constants/margin_top = 20 38 | 39 | [node name="ResultLabel" type="Label" parent="VBoxContainer/MarginContainer"] 40 | unique_name_in_owner = true 41 | custom_minimum_size = Vector2(500, 0) 42 | layout_mode = 2 43 | horizontal_alignment = 1 44 | autowrap_mode = 2 45 | 46 | [node name="FileDialog" type="FileDialog" parent="."] 47 | unique_name_in_owner = true 48 | initial_position = 1 49 | title = "Open a File" 50 | size = Vector2i(750, 400) 51 | ok_button_text = "Open" 52 | file_mode = 0 53 | access = 2 54 | filters = PackedStringArray("*.tres; Resources") 55 | 56 | [connection signal="pressed" from="VBoxContainer/HBoxContainer/OpenSafeButton" to="." method="_on_open_safe_button_pressed"] 57 | [connection signal="pressed" from="VBoxContainer/HBoxContainer/OpenUnsafeButton" to="." method="_on_open_unsafe_button_pressed"] 58 | [connection signal="file_selected" from="FileDialog" to="." method="_on_file_dialog_file_selected"] 59 | -------------------------------------------------------------------------------- /safe_resource_loader_example/saved_game.gd: -------------------------------------------------------------------------------- 1 | ## This is an example resource simulating a saved game. 2 | class_name SRLSavedGame 3 | extends Resource 4 | 5 | ## The current health of the player 6 | @export var health:int = 100 7 | 8 | ## A sub-resource with a property having `path` in its name. This should not be detected as false positive. 9 | @export var sub_resource:SavedGameSubResource = SavedGameSubResource.new() 10 | 11 | @export var some_array:Array[int] = [1,2,3] 12 | 13 | @export var some_dictionary:Dictionary = { 14 | "foo" : "bar" 15 | } 16 | -------------------------------------------------------------------------------- /safe_resource_loader_example/saved_game_subresource.gd: -------------------------------------------------------------------------------- 1 | class_name SavedGameSubResource 2 | extends Resource 3 | 4 | @export var some_path:NodePath 5 | -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/.gdignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derkork/godot-safe-resource-loader/377a659b2d74f186c8ba265d5e92355525a71835/safe_resource_loader_example/test_resources/.gdignore -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/README.md: -------------------------------------------------------------------------------- 1 | This folder is excluded so Godot will not try to mess with the resources when adding UIDs in Godot 4.4. or later. -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/another_unsafe_resource.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" script_class="SRLSavedGame" load_steps=4 format=3 uid="uid://cx4rf0ybe3u7o"] 2 | 3 | [ext_resource type="Script" path="res://safe_resource_loader_example/saved_game.gd" id="1_on72l"] 4 | 5 | [ ext_resource path="my_script.gd" type="Script" id="4711"] 6 | 7 | [sub_resource type="Resource" id="Resource_a4lfc"] 8 | script = ExtResource("4711") 9 | 10 | [resource] 11 | script = ExtResource("1_on72l") 12 | health = 200 13 | metadata/hack = SubResource("Resource_a4lfc") 14 | -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/my_script.gd: -------------------------------------------------------------------------------- 1 | func _init(): 2 | OS.alert("Heya I just executed code via a side-loaded script!") 3 | -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/safe_resource.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" script_class="SRLSavedGame" load_steps=4 format=3 uid="uid://odold24tp61k"] 2 | 3 | [ext_resource type="Script" path="res://safe_resource_loader_example/saved_game.gd" id="1_277en"] 4 | [ext_resource type="Script" path="res://safe_resource_loader_example/saved_game_subresource.gd" id="2_qve2v"] 5 | 6 | [sub_resource type="Resource" id="Resource_75pld"] 7 | script = ExtResource("2_qve2v") 8 | some_path = NodePath("VBoxContainer") 9 | 10 | [resource] 11 | script = ExtResource("1_277en") 12 | health = 200 13 | sub_resource = SubResource("Resource_75pld") 14 | some_array = Array[int]([1, 2, 3]) 15 | some_dictionary = { 16 | "foo": "bar" 17 | } 18 | -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/unsafe_resource.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" script_class="SRLSavedGame" load_steps=4 format=3 uid="uid://cx4rf0ybe3u7o"] 2 | 3 | [ext_resource type="Script" path="res://safe_resource_loader_example/saved_game.gd" id="1_on72l"] 4 | 5 | [sub_resource type="GDScript" id="4711"] 6 | script/source = "extends Resource 7 | func _init(): 8 | OS.alert(\"Hey I just executed code!\") 9 | " 10 | 11 | [sub_resource type="Resource" id="Resource_a4lfc"] 12 | script = SubResource("4711") 13 | 14 | [resource] 15 | script = ExtResource("1_on72l") 16 | health = 200 17 | metadata/hack = SubResource("Resource_a4lfc") 18 | -------------------------------------------------------------------------------- /safe_resource_loader_example/test_resources/yet_another_unsafe_resource.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" script_class="SRLSavedGame" load_steps=4 format=3 uid="uid://cx4rf0ybe3u7o"] 2 | 3 | [ext_resource type="Script" path="res://safe_resource_loader_example/saved_game.gd" id="1_on72l"] 4 | 5 | [ ext_resource path="res://safe_resource_loader_example/saved_game.gd" 6 | path= "my_script.gd" type="Script" id="4711"] 7 | 8 | [sub_resource type="Resource" id="Resource_a4lfc"] 9 | script = ExtResource("4711") 10 | 11 | [resource] 12 | script = ExtResource("1_on72l") 13 | health = 200 14 | metadata/hack = SubResource("Resource_a4lfc") 15 | --------------------------------------------------------------------------------