From a1f4b6b06cf1459557ad662fc643eac3d35c085d Mon Sep 17 00:00:00 2001 From: Treeki Date: Sat, 7 Jan 2012 05:32:34 +0100 Subject: initial commit --- README | 75 ++++++ classy.py | 886 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 961 insertions(+) create mode 100644 README create mode 100644 classy.py diff --git a/README b/README new file mode 100644 index 0000000..39b7b88 --- /dev/null +++ b/README @@ -0,0 +1,75 @@ +Classy! + +IDA script for keeping track of RE info related to C++ classes (specifically, + CodeWarrior PPC) and keeping things consistent throughout the database. + +That's what makes IDA better than objdump anyway, right? :p + +Started by Treeki: 16th October 2011 +Developed using Python 2.6 and IDA 6.1. + + + +Supported so far: +- Save and reset database +- Classes: + - Create a class using an IDA struct + - Base classes are handled using a struct field named _ + - Struct is automatically created if one is not chosen - if this is used, + Classy adds the _ field for you +- Virtual Tables: + - Set a VTable for a class. End is automatically detected + - If the auto detection fails (pure virtuals, for example) you can + manually override it +- CTors/DTors: + - Set a ctor and dtor for a class (TODO: Multiple ctors) + - Dtors automatically become virtual if a pointer to them is found in the + vtable, even if the dtor was registered before the vtable was set +- Methods: + - Register regular and virtual methods + - Mark virtual calls by highlighting the "lwz r12, ..." line, pressing + Shift+C and choosing a class + - Xrefs to each virtual method are shown at the top of the method body + - Set method arguments including pointers, arrays, refs and consts + - Arguments are automatically mangled so they are shown in IDA names + - Shift+V automatically detects whether a method is a new virtual one or + an override + - Overrides are shown at the top of the original method body + - Renames and argument list changes are propagated from original methods + to overrides (Though not vice versa yet) + +That's all for now, I think... + + + + +Current Todo: +- Only show classes that fit the criteria in the chooser for Shift+C +- Support PTMFs, too +- Automatically create virtual methods using the vtable. +- Named arguments to methods +- Return types for methods +- Const methods +- Improve vtable end detection heuristics +- Support pure virtuals. +- Handle IDA chooser history/defaults correctly everywhere +- Better keybindings +- Rename class menu item +- Netnodes +- Override dtors +- Sanity check to see if a virtual method might exist in a base class +- Confirm unlinking of methods +- Don't let you create more than one method with the same name +- choose_class differentiation between "no class" and Cancel +- Auto usage of struct if a named one exists +- Remove virtual xrefs + +Caveats: +- Multiple inheritance will not be supported in the near future. +- Probably won't work as well with non-CodeWarrior stuff. + +Notes: +- When autocreating virtual methods, start with the base class!! + Otherwise, you might end up defining an override as a virtual + method. And that would be bad. + diff --git a/classy.py b/classy.py new file mode 100644 index 0000000..b032e94 --- /dev/null +++ b/classy.py @@ -0,0 +1,886 @@ +# Classy! + +# IDA script for keeping track of RE info related to C++ classes (specifically, +# CodeWarrior PPC) and keeping things consistent throughout the database. + +# That's what makes IDA better than objdump anyway, right? :p + +# Started by Treeki: 16th October 2011 +# Developed using Python 2.6 and IDA 6.1. + +import idaapi, cPickle, re + +print('Loading Classy') + + +# DATABASE +#DB_PATH = '/home/me/Dev/Reversing/nsmb2.cy' +DB_PATH = 'Z:/stuff/Dev/Reversing/nsmb2.cy' +DB_VERSION = 1 + +class ClassyDatabase(dict): + def __init__(self, path): + try: + handle = open(path, 'rb') + db = cPickle.load(handle) + self.update(db) + + if 'version' not in db: + # old DB + self.version = 1 + except IOError: + pass + except EOFError: + pass + + self.path = path + + if 'version' not in self: + # totally new DB + self.initialise() + else: + self.upgrade() + + def __getattr__(self, key): + try: + return self[key] + except KeyError: + default = self.default_for(key) + self[key] = default + return self[key] + + def __setattr__(self, key, value): + self[key] = value + + HASH_DEFAULTS = [ + 'classes_by_name', 'classes_by_struct', + 'known_methods', 'virtual_calls', + ] + + NONE_DEFAULTS = [ + 'last_class_name', + ] + + def default_for(self, key): + if key in self.HASH_DEFAULTS: + return {} + elif key in self.NONE_DEFAULTS: + return None + else: + raise AttributeError + + def clear(self): + path = self.path + dict.clear(self) + self.path = path + self.initialise() + + def initialise(self): + self.version = DB_VERSION + + def upgrade(self): + pass + + def save(self): + cPickle.dump(self, open(self.path, 'wb')) + +def setup_db(): + global db + db = ClassyDatabase(DB_PATH) + +# MENUS +cy_menu_defs = [] + +def setup_menus(): + global cy_menus + + try: + cy_menus + for item in cy_menus.values(): + idaapi.del_menu_item(item) + except: + pass + + cy_menus = {} + cy_menus['Classy'] = idaapi.add_menu_item( + 'Edit/', 'Classy', '', 0, about_classy, None) + + for name, sequence, callback in cy_menu_defs: + cy_menus[name] = idaapi.add_menu_item( + 'Edit/Classy', "CY: %s" % name, sequence, + 0x04000000, # SETMENU_CTXIDA + callback, None) + + +def menu_item(name, sequence=''): + def custom_decorator(func): + cy_menu_defs.append((name, sequence, func)) + + def wrapper(): + try: + func() + except StandardError, e: + Warning('Crap.') + print e + + return wrapper + return custom_decorator + + +def about_classy(): + print('Classy!') + + +# CLASSES +class Class(object): + def __init__(self, name, struct, base): + if type(struct) == idaapi.struc_t: + struct = struct.id + + self.name = name + self.mangled_name = self.mangle(name) + self.struct = struct + self.methods = [] + self.vmethods = [] + self.vmethods_by_offset = {} + self.vtable = None + self.base = base + + self.ctor = NullMethod(self) + self.dtor = NullMethod(self) + + db.classes_by_name[name] = self + db.classes_by_struct[struct] = self + + @staticmethod + def mangle(name): + components = name.split('::') + if len(components) > 1: + begin = "Q%d" % len(components) + m_comps = ["%d%s" % (len(n), n) for n in components] + return begin + ''.join(m_comps) + else: + return "%d%s" % (len(name), name) + + def rename(self, name): + old_name = self.name + + del db.classes_by_name[old_name] + db.classes_by_name[name] = self + + self.name = name + self.mangled_name = self.mangle(name) + + self.refresh() + + def refresh(self): + self.ctor.refresh() + self.dtor.refresh() + + for m in self.methods: + m.refresh() + for m in self.vmethods: + m.refresh() + + def set_vtable(self, ea): + self.vtable = ea + self.find_vtable_end() + self.search_for_dtor() + + def set_vtable_end(self, ea): + self.vtable_end = ea + self.search_for_dtor() + + def find_vtable_end(self): + check = self.vtable + 4 + + while Dword(check + 4) != 0: + check += 4 + + print('Inferred vtable as %08x .. %08x' % (self.vtable, check)) + self.vtable_end = check + + def search_for_dtor(self): + if self.dtor.is_null(): return + if self.dtor.is_virtual(): return + + ea = self.dtor.ea + offset = self.vtable_contains(ea) + if offset: + self.dtor.unlink() + + # TODO: make this bit more DRY + base, base_method = (self.base and self.base.find_virtual(offset)) or (None, None) + if base_method is None: + self.dtor = VirtualMethod(self, '__dt', ea, offset) + else: + self.dtor = OverrideMethod(self, '__dt', ea, offset, base_method) + + self.dtor.mangled_args = 'Fv' + self.dtor.arg_signature = None # cannot be changed! + self.dtor.refresh() + + def iter_vtable(self): + check = self.vtable + end = self.vtable_end + + while check <= end: + yield (check, Dword(check)) + check += 4 + + def detect_overrides(self): + offset = 0 + + for addr, value in self.iter_vtable(): + addr + offset += 4 + + + def find_virtual(self, offset): + try: + return (self, self.vmethods_by_offset[offset]) + except KeyError: + if self.base is None: + return (self, None) + else: + return self.base.find_virtual(offset) + + def vtable_contains(self, ea): + for addr, value in self.iter_vtable(): + if value == ea: + return addr - self.vtable + + return None + + def generate_cpp_definition(self): + contents = [] + + vtable_found = False + + sid = self.struct + s = idaapi.get_struc(sid) + + comments = (idaapi.get_struc_cmt(sid, 0), idaapi.get_struc_cmt(sid, 1)) + for cmt in comments: + if len(cmt) > 0: contents.append("\t/* %s */" % cmt) + + gen_offset = 0 + + # note: structs should always include their base class + while ida_offset <= end_at: + m = idaapi.get_member(s, ida_offset) + mid = m.id + + comments = (idaapi.get_member_cmt(mid, 0), idaapi.get_member_cmt(mid, 1)) + for cmt in comments: + if len(cmt) > 0: contents.append("\t/* %s */" % cmt) + + # TODO: convert to swig api, figure out how the fuck I'll do this + + ida_offset = idaapi.get_struc_next_offset(s, ida_offset) + + base_info = (self.base and (" : public %s" % self.base.name)) or '' + return "class %s%s {\n%s\n};" % (self.name, base_info, "\n".join(contents)) + + +class Method(object): + def __init__(self, owner, name, ea, register=True): + self.owner = owner + self.name = name + self.arg_signature = '?' + self.mangled_args = '1?' + self.ea = ea + + if ea != BADADDR and ea != 0: + db.known_methods[ea] = self + + if register: + owner.methods.append(self) + + def __str__(self): + return repr(self) + def __repr__(self): + if self.arg_signature is None: + return "<%s %s::%s>" % (type(self), self.owner.name, self.name) + else: + return "<%s %s::%s(%s)>" % (type(self), self.owner.name, self.name, self.arg_signature) + + def is_null(self): return False + def is_virtual(self): return False + + def rename(self, name): + self.name = name + self.refresh() + + def refresh(self): + MakeName(self.ea, "%s__%s%s" % (self.name, self.owner.mangled_name, self.mangled_args)) + + # figure out if any virtual xrefs include us + # a bit slow, but who cares + call_dict = db.virtual_calls + func = idaapi.get_func(self.ea) + check = func.startEA + end = func.endEA + + done_already = {} + + while check <= end: + if check in call_dict: + method = call_dict[check] + if method not in done_already: + method.refresh_comment() + done_already[method] = True + check += 4 + + def unlink(self, orphan=False): + if self.name == '__ct': + self.owner.ctor = NullMethod(self.owner) + elif self.name == '__dt': + self.owner.dtor = NullMethod(self.owner) + + if self in self.owner.methods: + self.owner.methods.remove(self) + + self.owner = None + if self.ea != 0 and self.ea != BADADDR: + del db.known_methods[self.ea] + + if orphan: + MakeName(self.ea, '') + + def set_signature(self, sig): + if self.arg_signature is None: + raise TypeError, "this method has a fixed signature" + + mang = mangle_args(sig) + self.arg_signature = sig + self.mangled_args = mang + self.refresh() + + +class VirtualMethod(Method): + def __init__(self, owner, name, ea, vt_offset): + Method.__init__(self, owner, name, ea, False) + + self.vt_offset = vt_offset + self.virtual_calls = [] + self.overrides = [] + + owner.vmethods.append(self) + owner.vmethods_by_offset[vt_offset] = self + + def is_virtual(self): return True + + def unlink(self, orphan=False): + self.owner.vmethods.remove(self) + del self.owner.vmethods_by_offset[self.vt_offset] + + for m in self.overrides: + m.unlink(True) + + Method.unlink(self, orphan) + + def rename(self, name): + Method.rename(self, name) + for o in self.overrides: + o.rename(name, False) + o.refresh() + + def set_signature(self, sig): + Method.set_signature(self, sig) + for o in self.overrides: + o.copy_signature(self) + o.refresh() + + def add_xref(self, ea): + self.virtual_calls.append(ea) + self.virtual_calls.sort() + self.refresh_xref(ea) + self.refresh_comment() + db.virtual_calls[ea] = self + + def remove_xref(self, ea): + self.virtual_calls.remove(ea) + self.refresh_comment() + MakeComm(ea, '') + del db.virtual_calls[ea] + + def refresh(self): + Method.refresh(self) + self.refresh_comment() + for ea in self.virtual_calls: + self.refresh_xref(ea) + + def refresh_xref(self, ea): + MakeComm(ea, "%08X %s::%s(%s)" % (self.ea, self.owner.name, self.name, self.arg_signature)) + + def get_comment(self): + lines = [] + if len(self.overrides) > 0: + lines.append('OVERRIDDEN BY:') + for method in self.overrides: + lines.append(' - %08X : %s' % (method.ea, method.owner.name)) + lines.append('') + + if len(self.virtual_calls) > 0: + lines.append('VIRTUAL METHOD CALLS:') + for ea in self.virtual_calls: + func = idaapi.get_func(ea) + name = idaapi.demangle_name(idaapi.get_func_name(func.startEA), 0) + offset = ea - func.startEA + lines.append(' - %08X : %s + %X' % (ea, name, offset)) + lines.append('') + + return "\n".join(lines) + + def refresh_comment(self): + SetFunctionCmt(self.ea, self.get_comment(), 0) + + +class OverrideMethod(VirtualMethod): + def __init__(self, owner, name, ea, vt_offset, base): + VirtualMethod.__init__(self, owner, name, ea, vt_offset) + + if ea == BADADDR || ea == 0: + raise ValueError("An override method can't be pure!!") + + self.base = base + self.original = (hasattr(base, 'original') and base.original) or base + self.copy_signature(self.original) + + owner.vmethods.append(self) + owner.vmethods_by_offset[vt_offset] = self + + base.overrides.append(self) + + def unlink(self, orphan=False): + if self in self.base.overrides: + self.base.overrides.remove(self) + + VirtualMethod.unlink(self, orphan) + + def copy_signature(self, other): + self.arg_signature = other.arg_signature + self.mangled_args = other.mangled_args + + for m in self.overrides: + m.copy_signature(other) + + def rename(self, name, delegate_to_original=True): + # a strange little hack + if delegate_to_original: + self.original.rename(name) + else: + VirtualMethod.rename(self, name) + + def set_signature(self, sig): + self.original.set_signature(sig) + + def refresh(self): + VirtualMethod.refresh(self) + self.base.refresh_comment() + + def get_comment(self): + return "ORIGINAL: %08X : %s\nBASE: %08X : %s\n\n%s" % \ + (self.original.ea, self.original.owner.name, + self.base.ea, self.base.owner.name, + VirtualMethod.get_comment(self)) + + +class NullMethod(Method): + def __init__(self, owner): + Method.__init__(self, owner, '', BADADDR, False) + + def is_null(self): return True + + def refresh(self): pass + def unlink(self): pass + + +# UTILITY FUNCTIONS +class ClassChooser(Choose2): + def __init__(self): + Choose2.__init__(self, 'Choose a class', [['Class', 40]]) + + def OnClose(self): pass + def OnGetLine(self, n): return [self.clslist[n]] + def OnGetSize(self): return len(self.clslist) + + +_class_chooser = ClassChooser() + +def choose_class(title=None, optional=False): + names = db.classes_by_name.keys() + base_index = 0 + if optional: + names.insert(0, '[[ None ]]') + base_index = 1 + + _class_chooser.title = title or 'Choose a class' + try: + _class_chooser.deflt = names.index(db.last_class_name) + (2 if optional else 1) + except StandardError: + _class_chooser.deflt = 0 + _class_chooser.clslist = names + + num = _class_chooser.Show(True) + + if num < base_index: + return None + else: + choice = names[num] + db.last_class_name = choice + return db.classes_by_name[choice] + + +def unlink_method_if_exists(ea): + if ea in db.known_methods: + db.known_methods[ea].unlink() + + +def get_current_function(): + ea = idaapi.get_screen_ea() + func = idaapi.get_func(ea) + + if func is None: + # try whatever's pointed to by this + func = idaapi.get_func(Dword(ea)) + + if func is None: + # and if it's still none ... + Warning('Place the cursor on top of a function.') + raise ValueError + + return func.startEA + + +# DO STUFF +@menu_item('Create Class') +def create_class(): + name = idaapi.askstr(idaapi.HIST_IDENT, '', 'Enter a class name') + if name in db.classes_by_name: + Warning('That name is already used.') + return + + if name is None: + return + + struct = idaapi.choose_struc('Associate this class with a struct') + if struct is not None and struct.id in db.classes_by_struct: + Warning('That struct is already used.') + return + + base = choose_class('Choose the base class for this one', True) + + if struct is None: + safe_name = name.replace(':', '_') + + struct = idaapi.get_struc_id(safe_name) + if struct == BADADDR: + struct = idaapi.add_struc(BADADDR, safe_name, 0) + if struct == -1: + Warning('Oops.') + return + + print("Created struct: %s" % safe_name) + + if base != None: + AddStrucMember(struct, '_', 0, FF_DATA | FF_STRU, base.struct, GetStrucSize(base.struct)) + + else: + print("Used existing struct: %s" % safe_name) + + cls = Class(name, struct, base) + + print("Done!") + Refresh() + + +@menu_item('Set VTable') +def set_vtable(): + cls = choose_class() + if cls is None: return + + ea = idaapi.get_screen_ea() + cls.set_vtable(ea) + + print("Set vtable for %s to %08x" % (cls.name, ea)) + Refresh() + + +@menu_item('Set VTable End') +def set_vtable_end(): + cls = choose_class() + if cls is None: return + + ea = idaapi.get_screen_ea() + if ea < cls.vtable: + Warning("The virtual table cannot go backwards. It starts at %08x." % cls.vtable) + return + + cls.set_vtable_end(ea) + + print("Set vtable end for %s to %08x" % (cls.name, ea)) + Refresh() + + +@menu_item('Set Ctor') +def set_ctor(): + cls = choose_class() + if cls is None: return + + ea = get_current_function() + unlink_method_if_exists(ea) + cls.ctor = Method(cls, '__ct', ea) + cls.ctor.refresh() + + print("Set ctor for %s to %08x" % (cls.name, ea)) + Refresh() + + +@menu_item('Set Dtor') +def set_dtor(): + cls = choose_class() + if cls is None: return + + ea = get_current_function() + unlink_method_if_exists(ea) + + # is it virtual? + v_offset = (cls.vtable and cls.vtable_contains(ea)) + if v_offset: + base, base_method = (cls.base and cls.base.find_virtual(v_offset)) or (None, None) + if base_method is None: + cls.dtor = VirtualMethod(cls, '__dt', ea, v_offset) + else: + cls.dtor = OverrideMethod(cls, '__dt', ea, v_offset, base_method) + else: + cls.dtor = Method(cls, '__dt', ea) + + cls.dtor.mangled_args = 'Fv' + cls.dtor.arg_signature = None # cannot be changed! + cls.dtor.refresh() + + print("Set dtor for %s to %08x" % (cls.name, ea)) + Refresh() + + +@menu_item('Register as Method', 'Shift+M') +def register_method(): + cls = choose_class() + if cls is None: return + + ea = get_current_function() + unlink_method_if_exists(ea) + + name = idaapi.askstr(idaapi.HIST_IDENT, 's_%08X' % ea, 'Enter a method name') + if name is None: return + method = Method(cls, name, ea) + method.refresh() + + print("Method %s::%s() created at %08x" % (cls.name, name, ea)) + Refresh() + + +@menu_item('Set Method Arguments', 'Shift+A') +def set_method_args(): + ea = get_current_function() + + try: + method = db.known_methods[ea] + except KeyError: + Warning("No method at this address (%08X)." % ea) + return + + if method.arg_signature is None: + Warning("This method's argument signature cannot be changed") + return + + arg = idaapi.askstr(idaapi.HIST_TYPE, method.arg_signature, 'Enter the arguments') + if arg is None: + return + + method.set_signature(arg) + + print("Method %s::%s() set args to %s" % (method.owner.name, method.name, method.mangled_args)) + Refresh() + + +@menu_item('Register as Virtual Method', 'Shift+V') +def register_virtual_method(): + cls = choose_class() + if cls is None: return + + if cls.vtable is None: + Warning('This class does not have a virtual table defined.') + return + + ea = get_current_function() + unlink_method_if_exists(ea) + + # try to find it within the vtable + offset = cls.vtable_contains(ea) + + if offset is None: + Warning("Could not find this method within the vtable for %s." % cls.name) + print("Virtual table for %s: %08x .. %08x" % (cls.name, cls.vtable, cls.vtable_end)) + return + + # check to make sure it's not defined anywhere in a base class + base, base_method = (cls.base and cls.base.find_virtual(offset)) or (None, None) + if base_method is None: + name = idaapi.askstr(idaapi.HIST_IDENT, 'vf%02X' % offset, 'Enter a method name') + if name is None: + return + method = VirtualMethod(cls, name, ea, offset) + else: + method = OverrideMethod(cls, base_method.name, ea, offset, base_method) + + method.refresh() + + print("Virtual Method %s::%s() created at %08x with offset %x" % (cls.name, method.name, ea, offset)) + Refresh() + + +@menu_item('Rename Method', 'Shift+N') +def rename_method(): + ea = get_current_function() + + if ea in db.known_methods: + method = db.known_methods[ea] + + name = idaapi.askstr(idaapi.HIST_IDENT, method.name, 'Enter a method name') + if name is None: return + + method.rename(name) + Refresh() + else: + print("Don't know about a method here") + + +@menu_item('Unlink Method', 'Shift+U') +def unlink_method(): + ea = get_current_function() + + if ea in db.known_methods: + db.known_methods[ea].unlink(True) + Refresh() + else: + print("Don't know about a method here") + + +@menu_item('Mark Virtual Call', 'Shift+C') +def mark_virtual_call(): + ea = idaapi.get_screen_ea() + + inslen = idaapi.decode_insn(ea) + if inslen == 0: raise ValueError + + op = idaapi.cmd.Operands[1] + if not op: raise ValueError + if op.type != idaapi.o_displ: raise ValueError + + chosen_cls = choose_class('Choose the class for this method') + offset = op.addr + + cls, method = chosen_cls.find_virtual(offset) + if method is None: + Warning("The class %s and its base classes do not have this method defined." % chosen_cls.name) + return + + if ea in db.virtual_calls: + db.virtual_calls[ea].remove_xref(ea) + + original = (hasattr(method, 'original') and method.original) or method + original.add_xref(ea) + Refresh() + + +@menu_item('Unmark Virtual Call', 'Shift+Ctrl+C') +def unmark_virtual_call(): + ea = idaapi.get_screen_ea() + if ea in db.virtual_calls: + db.virtual_calls[ea].remove_xref(ea) + Refresh() + + +@menu_item('Save') +def save_db(): + db.save() + print('Saved the database.') + + +@menu_item('Reset DB') +def reset_db(): + if idaapi.askyn_c(0, 'Are you really sure you want to lose EVERYTHING?') == 1: + db.clear() + print('Done. You asked for it ...') + + +MANGLED_TYPES = { + 's32': 'i', 'int': 'i', + 'u32': 'Ui', 'uint': 'Ui', 'unsigned int': 'Ui', + 's16': 's', 'short': 's', + 'u16': 'Us', 'ushort': 'Us', 'unsigned short': 'Us', + 's8': 'c', 'char': 'c', + 'u8': 'Uc', 'uchar': 'Uc', 'unsigned char': 'Uc', + 'bool': 'b', + 'wchar_t': 'w', + 'f32': 'f', 'float': 'f', + 'f64': 'd', 'double': 'd', + 'void': 'v', + } + +ARRAY_REGEX = re.compile(r'\[(\d+)\]$') + +# TYPE DECLARATIONS +def mangle_args(string): + pieces = [p.strip() for p in string.split(',')] + + # 'CF' for const methods + output = ['F'] + + for p in pieces: + type_name = p + prefix, postfix = '', '' + + while True: + if type_name.endswith('&'): + type_name = type_name[:-1].strip() + prefix = 'R' + prefix + elif type_name.endswith('*'): + type_name = type_name[:-1].strip() + prefix = 'P' + prefix + elif type_name.endswith('const'): + type_name = type_name[:-5].strip() + prefix = 'C' + prefix + else: + match = ARRAY_REGEX.search(type_name) + if match: + type_name = type_name[:type_name.rindex('[')].strip() + prefix = 'A%s_%s' % (match.group(1), prefix) + else: + # nothing, we must be done + break + + try: + type_def = MANGLED_TYPES[type_name] + except KeyError: + type_def = Class.mangle(type_name) + + output.append(prefix + type_def + postfix) + + if len(pieces) == 0: + output.append('v') + + return ''.join(output) + + + +# FINAL SETUP + +setup_menus() +setup_db() + +print('Done!') + -- cgit v1.2.3