# Donate CPU client library import shutil import os import subprocess import sys import socket import time import re import signal import tarfile import shlex import copy # Version scheme (MAJOR.MINOR.PATCH) should orientate on "Semantic Versioning" https://semver.org/ # Every change in this script should result in increasing the version number accordingly (exceptions may be cosmetic # changes) CLIENT_VERSION = "1.3.50" # Timeout for analysis with Cppcheck in seconds CPPCHECK_TIMEOUT = 30 * 60 CPPCHECK_REPO_URL = "https://github.com/danmar/cppcheck.git" # Return code that is used to mark a timed out analysis RETURN_CODE_TIMEOUT = -999 __jobs = '-j1' __server_address = ('cppcheck1.osuosl.org', 8000) __make_cmd = None def detect_make(): make_cmds = ['make', 'mingw32-make'] if sys.platform == 'win32': make_cmds.append('msbuild.exe') for m in make_cmds: try: #print('{} --version'.format(m)) subprocess.check_call([m, '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError as e: #print("'{}' not found ({})".format(m, e)) continue print("using '{}'".format(m)) return m print("Error: a make command ({}) is required".format(','.join(make_cmds))) return None def check_requirements(): result = True global __make_cmd __make_cmd = detect_make() if __make_cmd is None: result = False apps = ['git', 'wget'] if __make_cmd in ['make', 'mingw32-make']: apps.append('g++') apps.append('gdb') for app in apps: try: #print('{} --version'.format(app)) subprocess.check_call([app, '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError: print("Error: '{}' is required".format(app)) result = False try: import psutil except ImportError as e: print("Error: {}. Module is required.".format(e)) result = False return result # Try and retry with exponential backoff if an exception is raised def try_retry(fun, fargs=(), max_tries=5, sleep_duration=5.0, sleep_factor=2.0): for i in range(max_tries): try: return fun(*fargs) except KeyboardInterrupt as e: # Do not retry in case of user abort raise e except BaseException as e: if i < max_tries - 1: print("{} in {}: {}".format(type(e).__name__, fun.__name__, str(e))) print("Trying {} again in {} seconds".format(fun.__name__, sleep_duration)) time.sleep(sleep_duration) sleep_duration *= sleep_factor else: print("Maximum number of tries reached for {}".format(fun.__name__)) raise e def clone_cppcheck(repo_path, migrate_from_path): repo_git_dir = os.path.join(repo_path, '.git') if os.path.exists(repo_git_dir): return # Attempt to migrate clone directory used prior to 1.3.17 if os.path.exists(migrate_from_path): os.rename(migrate_from_path, repo_path) else: # A shallow git clone (depth = 1) is enough for building and scanning. # Do not checkout until fetch_cppcheck_version. subprocess.check_call(['git', 'clone', '--depth=1', '--no-checkout', CPPCHECK_REPO_URL, repo_path]) # Checkout an empty branch to allow "git worktree add" for main later on try: # git >= 2.27 subprocess.check_call(['git', 'switch', '--orphan', 'empty'], cwd=repo_path) except subprocess.CalledProcessError: subprocess.check_call(['git', 'checkout', '--orphan', 'empty'], cwd=repo_path) def checkout_cppcheck_version(repo_path, version, cppcheck_path): if not os.path.isabs(cppcheck_path): raise ValueError("cppcheck_path is not an absolute path") if os.path.exists(cppcheck_path): print('Checking out {}'.format(version)) subprocess.check_call(['git', 'checkout', '-f', version], cwd=cppcheck_path) # It is possible to pull branches, not tags if version != 'main': return False hash_old = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], cwd=cppcheck_path).strip() print('Pulling {}'.format(version)) subprocess.check_call(['git', 'pull'], cwd=cppcheck_path) hash_new = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], cwd=cppcheck_path).strip() has_changes = hash_old != hash_new if not has_changes: print('No changes detected') return has_changes else: if version != 'main': print('Fetching {}'.format(version)) # Since this is a shallow clone, explicitly fetch the remote version tag refspec = 'refs/tags/' + version + ':ref/tags/' + version subprocess.check_call(['git', 'fetch', '--depth=1', 'origin', refspec], cwd=repo_path) print('Adding worktree \'{}\' for {}'.format(cppcheck_path, version)) subprocess.check_call(['git', 'worktree', 'add', cppcheck_path, version], cwd=repo_path) return True def get_cppcheck_info(cppcheck_path): try: return subprocess.check_output(['git', 'show', "--pretty=%h (%ci)", 'HEAD', '--no-patch', '--no-notes'], universal_newlines=True, cwd=cppcheck_path).strip() except: return '' def __get_cppcheck_binary(cppcheck_path): if __make_cmd == "msbuild.exe": return os.path.join(cppcheck_path, 'bin', 'cppcheck.exe') if __make_cmd == 'mingw32-make': return os.path.join(cppcheck_path, 'cppcheck.exe') return os.path.join(cppcheck_path, 'cppcheck') def has_binary(cppcheck_path): cppcheck_bin = __get_cppcheck_binary(cppcheck_path) if __make_cmd == "msbuild.exe": if os.path.isfile(cppcheck_bin): return True elif __make_cmd == 'mingw32-make': if os.path.isfile(cppcheck_bin): return True elif os.path.isfile(cppcheck_bin): return True return False def compile_version(cppcheck_path): if has_binary(cppcheck_path): return True # Build ret = compile_cppcheck(cppcheck_path) # Clean intermediate build files if __make_cmd == "msbuild.exe": exclude_bin = 'bin' elif __make_cmd == 'mingw32-make': exclude_bin = 'cppcheck.exe' else: exclude_bin = 'cppcheck' # TODO: how to support multiple compilers on the same machine? this will clean msbuild.exe files in a mingw32-make build and vice versa subprocess.check_call(['git', 'clean', '-f', '-d', '-x', '--exclude', exclude_bin], cwd=cppcheck_path) return ret def compile_cppcheck(cppcheck_path): print('Compiling {}'.format(os.path.basename(cppcheck_path))) cppcheck_bin = __get_cppcheck_binary(cppcheck_path) # remove file so interrupted "main" branch compilation is being resumed if os.path.isfile(cppcheck_bin): os.remove(cppcheck_bin) try: if __make_cmd == 'msbuild.exe': subprocess.check_call(['python3', os.path.join('tools', 'matchcompiler.py'), '--write-dir', 'lib'], cwd=cppcheck_path) build_env = os.environ # append to cl.exe options - need to omit dash or slash since a dash is being prepended build_env["_CL_"] = __jobs.replace('j', 'MP', 1) # TODO: processes still exhaust all threads of the system subprocess.check_call([__make_cmd, '-t:cli', os.path.join(cppcheck_path, 'cppcheck.sln'), '/property:Configuration=Release;Platform=x64'], cwd=cppcheck_path, env=build_env) else: build_cmd = [__make_cmd, __jobs, 'MATCHCOMPILER=yes', 'CXXFLAGS=-O2 -g -w'] build_env = os.environ if __make_cmd == 'mingw32-make': # TODO: MinGW will always link even if no changes are present # assume Python is in PATH for now build_env['PYTHON_INTERPRETER'] = 'python3' # TODO: MinGW is not detected by Makefile - so work around it for now build_cmd.append('RDYNAMIC=-lshlwapi') subprocess.check_call(build_cmd, cwd=cppcheck_path, env=build_env) except Exception as e: print('Compilation failed: {}'.format(e)) return False try: if __make_cmd == 'msbuild.exe': subprocess.check_call([os.path.join(cppcheck_path, 'bin', 'cppcheck.exe'), '--version'], cwd=os.path.join(cppcheck_path, 'bin')) else: subprocess.check_call([os.path.join(cppcheck_path, 'cppcheck'), '--version'], cwd=cppcheck_path) except Exception as e: print('Running Cppcheck failed: {}'.format(e)) # remove faulty binary if os.path.isfile(cppcheck_bin): os.remove(cppcheck_bin) return False return True def get_cppcheck_versions(): print('Connecting to server to get Cppcheck versions..') with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect(__server_address) sock.send(b'GetCppcheckVersions\n') versions = sock.recv(256) # TODO: sock.recv() sometimes hangs and returns b'' afterwards if not versions: raise Exception('received empty response') return versions.decode('utf-8').split() def get_packages_count(): def __get_packages_count(): print('Connecting to server to get count of packages..') with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect(__server_address) sock.send(b'getPackagesCount\n') packages = sock.recv(64) # TODO: sock.recv() sometimes hangs and returns b'' afterwards if not packages: raise Exception('received empty response') return int(packages) return try_retry(__get_packages_count) def get_package(package_index=None): def __get_package(package_index): print('Connecting to server to get assigned work..') with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect(__server_address) if package_index is None: sock.send(b'get\n') else: request = 'getPackageIdx:' + str(package_index) + '\n' sock.send(request.encode()) package = sock.recv(256) # TODO: sock.recv() sometimes hangs and returns b'' afterwards if not package: raise Exception('received empty response') return package.decode('utf-8') return try_retry(__get_package, fargs=(package_index,), max_tries=3, sleep_duration=30.0, sleep_factor=1.0) def __handle_remove_readonly(func, path, exc): import stat if not os.access(path, os.W_OK): # Is the error an access error ? os.chmod(path, stat.S_IWUSR) func(path) def __remove_tree(folder_name): if not os.path.exists(folder_name): return def rmtree_func(): shutil.rmtree(folder_name, onerror=__handle_remove_readonly) print('Removing existing temporary data...') try: try_retry(rmtree_func, max_tries=5, sleep_duration=30, sleep_factor=1) except Exception as e: print('Failed to cleanup {}: {}'.format(folder_name, e)) sys.exit(1) def __wget(url, destfile, bandwidth_limit): if os.path.exists(destfile): if os.path.isfile(destfile): os.remove(destfile) else: print('Error: ' + destfile + ' exists but it is not a file! Please check the path and delete it manually.') sys.exit(1) wget_call = ['wget', '--tries=10', '--timeout=300', '-O', destfile, url] if bandwidth_limit and isinstance(bandwidth_limit, str): wget_call.append('--limit-rate=' + bandwidth_limit) exitcode = subprocess.call(wget_call) if exitcode != 0: print('wget failed with ' + str(exitcode)) os.remove(destfile) return False if not os.path.isfile(destfile): return False return True def download_package(work_path, package, bandwidth_limit): print('Download package ' + package) destfile = os.path.join(work_path, 'temp.tgz') if not __wget(package, destfile, bandwidth_limit): return None return destfile def unpack_package(work_path, tgz, cpp_only=False, c_only=False, skip_files=None): temp_path = os.path.join(work_path, 'temp') __remove_tree(temp_path) os.mkdir(temp_path) header_endings = ('.hpp', '.h++', '.hxx', '.hh', '.h') cpp_source_endings = ('.cpp', '.c++', '.cxx', '.cc', '.tpp', '.txx', '.ipp', '.ixx', '.qml') c_source_endings = ('.c',) if cpp_only: source_endings = cpp_source_endings elif c_only: source_endings = c_source_endings else: source_endings = cpp_source_endings + c_source_endings source_found = False if tarfile.is_tarfile(tgz): print('Unpacking..') with tarfile.open(tgz) as tf: total = 0 extracted = 0 skipped = 0 for member in tf: total += 1 if member.name.startswith(('/', '..')): # Skip dangerous file names # print('skipping dangerous file: ' + member.name) skipped += 1 continue is_source = member.name.lower().endswith(source_endings) if is_source or member.name.lower().endswith(header_endings): if skip_files is not None: skip = False for skip_file in skip_files: if member.name.endswith('/' + skip_file): # print('found file to skip: ' + member.name) skip = True break if skip: skipped += 1 continue try: tf.extract(member.name, temp_path) if is_source: source_found = True extracted += 1 except OSError: pass except AttributeError: pass print('extracted {} of {} files (skipped {}{})'.format(extracted, total, skipped, (' / only headers' if (extracted and not source_found) else ''))) return temp_path, source_found def __run_command(cmd, print_cmd=True): if print_cmd: print(cmd) time_start = time.time() comm = None if sys.platform == 'win32': p = subprocess.Popen(shlex.split(cmd, comments=False, posix=False), stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, errors='surrogateescape') else: p = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, errors='surrogateescape', preexec_fn=os.setsid) try: comm = p.communicate(timeout=CPPCHECK_TIMEOUT) return_code = p.returncode p = None except subprocess.TimeoutExpired: import psutil return_code = RETURN_CODE_TIMEOUT # terminate all the child processes so we get messages about which files were hanging child_procs = psutil.Process(p.pid).children(recursive=True) if len(child_procs) > 0: for child in child_procs: child.terminate() try: # call with timeout since it might get stuck e.g. gcc-arm-none-eabi comm = p.communicate(timeout=5) p = None except subprocess.TimeoutExpired: pass finally: if p: os.killpg(os.getpgid(p.pid), signal.SIGTERM) # Send the signal to all the process groups comm = p.communicate() time_stop = time.time() stdout, stderr = comm elapsed_time = time_stop - time_start return return_code, stdout, stderr, elapsed_time def scan_package(cppcheck_path, source_path, libraries, capture_callstack=True): print('Analyze..') libs = '' for library in libraries: if os.path.exists(os.path.join(cppcheck_path, 'cfg', library + '.cfg')): libs += '--library=' + library + ' ' dir_to_scan = source_path # TODO: temporarily disabled timing information - use --showtime=top5_summary when next version is released # TODO: remove missingInclude disabling when it no longer is implied by --enable=information # Reference for GNU C: https://gcc.gnu.org/onlinedocs/cpp/Common-Predefined-Macros.html options = libs + ' --check-library --inconclusive --enable=style,information --inline-suppr --disable=missingInclude --suppress=unmatchedSuppression --template=daca2' options += ' --debug-warnings --suppress=autoNoType --suppress=valueFlowBailout --suppress=bailoutUninitVar --suppress=symbolDatabaseWarning' options += ' -D__GNUC__ --platform=unix64' options_rp = options + ' -rp={}'.format(dir_to_scan) if __make_cmd == 'msbuild.exe': cppcheck_cmd = os.path.join(cppcheck_path, 'bin', 'cppcheck.exe') + ' ' + options_rp cmd = cppcheck_cmd + ' ' + __jobs + ' ' + dir_to_scan else: nice_cmd = 'nice' if __make_cmd == 'mingw32-make': nice_cmd = '' cppcheck_cmd = os.path.join(cppcheck_path, 'cppcheck') + ' ' + options_rp cmd = nice_cmd + ' ' + cppcheck_cmd + ' ' + __jobs + ' ' + dir_to_scan returncode, stdout, stderr, elapsed_time = __run_command(cmd) # collect messages information_messages_list = [] issue_messages_list = [] internal_error_messages_list = [] count = 0 re_obj = None for line in stderr.split('\n'): if ': information: ' in line: information_messages_list.append(line + '\n') elif line: issue_messages_list.append(line + '\n') if re_obj is None: re_obj = re.compile(r'.*:[0-9]+:.*\]$') if re_obj.match(line): count += 1 if ': error: Internal error: ' in line: internal_error_messages_list.append(line + '\n') print('Number of issues: ' + str(count)) # Collect timing information stdout_lines = stdout.split('\n') timing_info_list = [] overall_time_found = False max_timing_lines = 6 current_timing_lines = 0 for reverse_line in reversed(stdout_lines): if reverse_line.startswith('Overall time:'): overall_time_found = True if overall_time_found: if not reverse_line or current_timing_lines >= max_timing_lines: break timing_info_list.insert(0, ' ' + reverse_line + '\n') current_timing_lines += 1 timing_str = ''.join(timing_info_list) # detect errors sig_file = None sig_num = -1 for ie_line in internal_error_messages_list: # temp/dlib-19.10/dlib/test/dnn.cpp:0:0: error: Internal error: Child process crashed with signal 11 [cppcheckError] if 'Child process crashed with signal' in ie_line: sig_file = ie_line.split(':')[0] sig_msg = 'signal ' sig_pos = ie_line.find(sig_msg) if sig_pos != -1: sig_start_pos = sig_pos + len(sig_msg) sig_num = int(ie_line[sig_start_pos:ie_line.find(' ', sig_start_pos)]) # break on the first signalled file for now break print('cppcheck finished with ' + str(returncode) + ('' if sig_num == -1 else ' (signal ' + str(sig_num) + ')')) options_j = options + ' ' + __jobs if returncode == RETURN_CODE_TIMEOUT: print('Timeout!') return returncode, ''.join(internal_error_messages_list), '', elapsed_time, options_j, '' # generate stack trace for SIGSEGV, SIGABRT, SIGILL, SIGFPE, SIGBUS has_error = returncode in (-11, -6, -4, -8, -7) has_sig = sig_num in (11, 6, 4, 8, 7) if has_error or has_sig: print('Crash!') # make sure we have the actual error code set if has_sig: returncode = -sig_num stacktrace = '' if capture_callstack: # re-run within gdb to get a stacktrace cmd = 'gdb --batch --eval-command=run --eval-command="bt 50" --return-child-result --args ' + cppcheck_cmd + " -j1 " if sig_file is not None: cmd += sig_file else: cmd += dir_to_scan _, st_stdout, _, _ = __run_command(cmd) gdb_pos = st_stdout.find(" received signal") if not gdb_pos == -1: last_check_pos = st_stdout.rfind('Checking ', 0, gdb_pos) if last_check_pos == -1: stacktrace = st_stdout[gdb_pos:] else: stacktrace = st_stdout[last_check_pos:] # if no stacktrace was generated return the original stdout or internal errors list if not stacktrace: if has_sig: stacktrace = ''.join(internal_error_messages_list) else: stacktrace = stdout return returncode, stacktrace, '', returncode, options_j, '' if returncode != 0: # returncode is always 1 when this message is written thr_pos = stderr.find('#### ThreadExecutor') if thr_pos != -1: print('Thread!') return -222, stderr[thr_pos:], '', -222, options_j, '' print('Error!') if returncode > 0: returncode = -100-returncode return returncode, stdout, '', returncode, options_j, '' if sig_num != -1: print('Signal!') return -sig_num, ''.join(internal_error_messages_list), '', -sig_num, options_j, '' return count, ''.join(issue_messages_list), ''.join(information_messages_list), elapsed_time, options_j, timing_str def __split_results(results): ret = [] w = None re_obj = None for line in results.split('\n'): if line.endswith(']'): if re_obj is None: re_obj = re.compile(r': (error|warning|style|performance|portability|information|debug):') if re_obj.search(line): if w is not None: ret.append(w.strip()) w = '' if w is not None: w += ' ' * 5 + line + '\n' if w is not None: ret.append(w.strip()) return ret def diff_results(ver1, results1, ver2, results2): print('Diff results..') ret = '' r1 = sorted(__split_results(results1)) r2 = sorted(__split_results(results2)) i1 = 0 i2 = 0 while i1 < len(r1) and i2 < len(r2): if r1[i1] == r2[i2]: i1 += 1 i2 += 1 elif r1[i1] < r2[i2]: ret += ver1 + ' ' + r1[i1] + '\n' i1 += 1 else: ret += ver2 + ' ' + r2[i2] + '\n' i2 += 1 while i1 < len(r1): ret += ver1 + ' ' + r1[i1] + '\n' i1 += 1 while i2 < len(r2): ret += ver2 + ' ' + r2[i2] + '\n' i2 += 1 # if there are syntaxError/unknownMacro/etc then analysis stops. # diffing normal checker warnings will not make much sense bailout_ids = ('[syntaxError]', '[unknownMacro]') has_bailout_id = False for id in bailout_ids: if (id in results1) or (id in results1): has_bailout_id = True if has_bailout_id: def check_bailout(line): for id in bailout_ids: if line.endswith(id): return True return False out = '' for line in ret.split('\n'): if check_bailout(line): out += line + '\n' ret = out return ret def __send_all(connection, data): bytes_ = data.encode('ascii', 'ignore') while bytes_: num = connection.send(bytes_) if num < len(bytes_): bytes_ = bytes_[num:] else: bytes_ = None def __upload(cmd, data, cmd_info): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect(__server_address) __send_all(sock, '{}\n{}'.format(cmd, data)) print('{} has been successfully uploaded.'.format(cmd_info)) return True def upload_results(package, results): if not __make_cmd == 'make': print('Error: Result upload not performed - only make build binaries are currently fully supported') return False print('Uploading results.. ' + str(len(results)) + ' bytes') try: try_retry(__upload, fargs=('write\n' + package, results + '\nDONE', 'Result'), max_tries=20, sleep_duration=15, sleep_factor=1) except Exception as e: print('Result upload failed ({})!'.format(e)) return False return True def upload_info(package, info_output): if not __make_cmd == 'make': print('Error: Information upload not performed - only make build binaries are currently fully supported') return False print('Uploading information output.. ' + str(len(info_output)) + ' bytes') try: try_retry(__upload, fargs=('write_info\n' + package, info_output + '\nDONE', 'Information'), max_tries=20, sleep_duration=15, sleep_factor=1) except Exception as e: print('Information upload failed ({})!'.format(e)) return False return True def upload_nodata(package): print('Uploading no-data status..') try: try_retry(__upload, fargs=('write_nodata\n' + package, '', 'No-data status'), max_tries=3, sleep_duration=30, sleep_factor=1) except Exception as e: print('No-data upload failed ({})!'.format(e)) return False return True class LibraryIncludes: def __init__(self): include_mappings = {'boost': ['', '', '','', '', '', ''], 'cairo': [''], 'cppunit': [''], 'gtk': ['', '', '', ''], 'libcurl': [''], 'libsigc++': ['', '"lua.h"'], 'mfc': ['', '', ''], 'microsoft_atl': [''], 'microsoft_sal': [''], 'motif': ['', '"prtypes.h"'], 'ntl': ['', '', ''], 'openmp': [''], 'openssl': ['', '"pcre.h"'], 'python': ['', '"Python.h"'], 'qt': ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''], 'sqlite3': ['', '"sqlite3.h"'], 'tinyxml2': [''], } self.__library_includes_re = {} for library, includes in include_mappings.items(): re_includes = [re.escape(inc) for inc in includes] re_expr = '^[ \\t]*#[ \\t]*include[ \\t]*(?:' + '|'.join(re_includes) + ')' re_obj = re.compile(re_expr, re.MULTILINE) self.__library_includes_re[library] = re_obj def __iterate_files(self, path, has_include_cb): for root, _, files in os.walk(path): for name in files: filename = os.path.join(root, name) try: with open(filename, 'rt', errors='ignore') as f: filedata = f.read() has_include_cb(filedata) except IOError: pass def get_libraries(self, folder): print('Detecting library usage...') libraries = ['posix', 'gnu'] # explicitly copy as assignments in python are references library_includes_re = copy.copy(self.__library_includes_re) def has_include(filedata): lib_del = [] for library, includes_re in library_includes_re.items(): if includes_re.search(filedata): libraries.append(library) lib_del.append(library) for lib_d in lib_del: del library_includes_re[lib_d] self.__iterate_files(folder, has_include) print('Found libraries: {}'.format(libraries)) return libraries def get_compiler_version(): if __make_cmd == 'msbuild.exe': _, _, stderr, _ = __run_command('cl.exe', False) return stderr.split('\n')[0] _, stdout, _, _ = __run_command('g++ --version', False) return stdout.split('\n')[0] def get_client_version(): return CLIENT_VERSION def set_server_address(server_address): global __server_address __server_address = server_address def set_jobs(jobs: str): global __jobs __jobs = jobs library_includes = LibraryIncludes()