# Copyright (c) 2020-present PlatformIO # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os import shutil import tempfile from xml.etree.ElementTree import fromstring import click from platformio import proc from platformio.check.defect import DefectItem from platformio.check.tools.base import CheckToolBase from platformio.compat import IS_WINDOWS class PvsStudioCheckTool(CheckToolBase): # pylint: disable=too-many-instance-attributes def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tmp_dir = tempfile.mkdtemp(prefix="piocheck") self._tmp_preprocessed_file = self._generate_tmp_file_path() + ".i" self._tmp_output_file = self._generate_tmp_file_path() + ".pvs" self._tmp_cfg_file = self._generate_tmp_file_path() + ".cfg" self._tmp_cmd_file = self._generate_tmp_file_path() + ".cmd" self.tool_path = os.path.join( self.get_tool_dir("tool-pvs-studio"), "x64" if IS_WINDOWS else "bin", "pvs-studio", ) with open(self._tmp_cfg_file, mode="w", encoding="utf8") as fp: fp.write( "exclude-path = " + self.config.get("platformio", "packages_dir").replace("\\", "/") ) with open(self._tmp_cmd_file, mode="w", encoding="utf8") as fp: fp.write( " ".join( ['-I"%s"' % inc.replace("\\", "/") for inc in self.cpp_includes] ) ) def tool_output_filter(self, line): # pylint: disable=arguments-differ if any( err_msg in line.lower() for err_msg in ( "license was not entered", "license information is incorrect", ) ): self._bad_input = True return line def _process_defects(self, defects): for defect in defects: if not isinstance(defect, DefectItem): return if defect.severity not in self.options["severity"]: return self._defects.append(defect) if self._on_defect_callback: self._on_defect_callback(defect) def _demangle_report(self, output_file): converter_tool = os.path.join( self.get_tool_dir("tool-pvs-studio"), "HtmlGenerator" if IS_WINDOWS else os.path.join("bin", "plog-converter"), ) cmd = ( converter_tool, "-t", "xml", output_file, "-m", "cwe", "-m", "misra", "-a", # Enable all possible analyzers and defect levels "GA:1,2,3;64:1,2,3;OP:1,2,3;CS:1,2,3;MISRA:1,2,3", "--cerr", ) result = proc.exec_command(cmd) if result["returncode"] != 0: click.echo(result["err"]) self._bad_input = True return result["err"] def parse_defects(self, output_file): defects = [] report = self._demangle_report(output_file) if not report: self._bad_input = True return [] try: defects_data = fromstring(report) except: # pylint: disable=bare-except click.echo("Error: Couldn't decode generated report!") self._bad_input = True return [] for table in defects_data.iter("PVS-Studio_Analysis_Log"): message = table.find("Message").text category = table.find("ErrorType").text line = table.find("Line").text file_ = table.find("File").text defect_id = table.find("ErrorCode").text cwe = table.find("CWECode") cwe_id = None if cwe is not None: cwe_id = cwe.text.lower().replace("cwe-", "") misra = table.find("MISRA") if misra is not None: message += " [%s]" % misra.text severity = DefectItem.SEVERITY_LOW if category == "error": severity = DefectItem.SEVERITY_HIGH elif category == "warning": severity = DefectItem.SEVERITY_MEDIUM defects.append( DefectItem( severity, category, message, file_, line, id=defect_id, cwe=cwe_id ) ) return defects def configure_command(self, src_file): # pylint: disable=arguments-differ if os.path.isfile(self._tmp_output_file): os.remove(self._tmp_output_file) if not os.path.isfile(self._tmp_preprocessed_file): click.echo("Error: Missing preprocessed file for '%s'" % src_file) return "" cmd = [ self.tool_path, "--skip-cl-exe", "yes", "--language", "C" if src_file.endswith(".c") else "C++", "--preprocessor", "gcc", "--cfg", self._tmp_cfg_file, "--source-file", src_file, "--i-file", self._tmp_preprocessed_file, "--output-file", self._tmp_output_file, ] flags = self.get_flags("pvs-studio") if not self.is_flag_set("--platform", flags): cmd.append("--platform=arm") cmd.extend(flags) return cmd def _generate_tmp_file_path(self): # pylint: disable=protected-access return os.path.join(self._tmp_dir, next(tempfile._get_candidate_names())) def _prepare_preprocessed_file(self, src_file): if os.path.isfile(self._tmp_preprocessed_file): os.remove(self._tmp_preprocessed_file) flags = self.cxx_flags compiler = self.cxx_path if src_file.endswith(".c"): flags = self.cc_flags compiler = self.cc_path cmd = [ compiler, '"%s"' % src_file, "-E", "-o", '"%s"' % self._tmp_preprocessed_file, ] cmd.extend([f for f in flags if f]) cmd.extend(['"-D%s"' % d.replace('"', '\\"') for d in self.cpp_defines]) cmd.append('@"%s"' % self._tmp_cmd_file) # Explicitly specify C++ as the language used in .ino files if src_file.endswith(".ino"): cmd.insert(1, "-xc++") result = proc.exec_command(" ".join(cmd), shell=True) if result["returncode"] != 0 or result["err"]: if self.options.get("verbose"): click.echo(" ".join(cmd)) click.echo(result["err"]) self._bad_input = True def clean_up(self): super().clean_up() if os.path.isdir(self._tmp_dir): shutil.rmtree(self._tmp_dir) @staticmethod def is_check_successful(cmd_result): return ( "license" not in cmd_result["err"].lower() and cmd_result["returncode"] == 0 ) def check(self, on_defect_callback=None): self._on_defect_callback = on_defect_callback for scope, files in self.get_project_target_files( self.project_dir, self.options["src_filters"] ).items(): if scope not in ("c", "c++"): continue for src_file in files: self._prepare_preprocessed_file(src_file) cmd = self.configure_command(src_file) if self.options.get("verbose"): click.echo(" ".join(cmd)) if not cmd: self._bad_input = True continue result = self.execute_check_cmd(cmd) if result["returncode"] != 0: continue self._process_defects(self.parse_defects(self._tmp_output_file)) self.clean_up() return self._bad_input