Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .coverity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Coverity

`model.c` describes HIDAPI's nullable, owned device handles; cleanup, I/O and
threading remain source-derived. Upload it under **Analysis Settings → Modeling
File** to reanalyze the last build. It is analysis input, never runtime code.

`setup/` installs the capture tools. `merge.py` validates and combines native
Windows, macOS and Linux captures; `test_merge.py` checks its validation gates.
The [Checks workflow](../.github/workflows/checks.yml) runs capture and submission.
78 changes: 78 additions & 0 deletions .coverity/merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Merge complete native captures and reject missing or damaged coverage."""

import collections
import json
import pathlib
import re
import shutil
import subprocess
import sys


def cov(*args):
result = subprocess.run(
["timeout", "--kill-after=3s", "300s", "cov-manage-emit", *map(str, args)],
text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if result.stderr:
print(result.stderr, file=sys.stderr, end="")
result.check_returncode()
return result.stdout


def version(text):
release = re.search(r"\bversion (\S+)", text)
internal = re.search(r"^Internal version numbers: (.+)", text, re.MULTILINE)
if not release or not internal:
raise ValueError("Unrecognized Coverity version output")
return release.group(1) + " " + internal.group(1).strip()


def units(idir):
listing = cov("--dir", idir, "list")
print(listing, end="")
paths = re.findall(r"^\s*\d+ -> (.+)$", listing, re.MULTILINE)
if not paths or "errors)" in listing:
raise RuntimeError(f"Empty or incomplete capture: {idir}")
return collections.Counter(path.replace("\\", "/") for path in paths)


def merge(captures, target):
expected = collections.Counter()
counts = {}
tool_version = version(cov("--ident"))
required_sources = {
"windows": ("/windows/hid.c",),
"macos": ("/mac/hid.c",),
"linux": ("/linux/hid.c", "/libusb/hid.c"),
}
for platform, required in required_sources.items():
capture = captures / f"coverity-{platform}"
if version((capture / "coverity-version.txt").read_text(encoding="utf-8-sig")) != tool_version:
raise RuntimeError(f"Capture tool version mismatch: {platform}")
idir = capture / "cov-int"
cov("--dir", idir, "reset-host-name")
cov("--dir", idir, "check-compatible")
cov("--dir", idir, "check-integrity")
paths = units(idir)
for suffix in required:
if not any(path.endswith(suffix) for path in paths):
raise RuntimeError(f"Missing backend: {suffix}")
counts[platform] = paths.total()
expected.update(paths)

# The target must be an existing capture; do not edit emit/ or its locks.
shutil.copytree(captures / "coverity-linux" / "cov-int", target)
for platform in ("windows", "macos"):
cov("--dir", target, "add", captures / f"coverity-{platform}" / "cov-int")
cov("--dir", target, "check-compatible")
cov("--dir", target, "check-integrity")
if units(target) != expected:
raise RuntimeError("Merged translation units differ from the native captures")
report = {"tool_version": tool_version, "captures": counts, "merged": expected.total()}
pathlib.Path("coverity-merge.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))


if __name__ == "__main__":
merge(pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]))
55 changes: 55 additions & 0 deletions .coverity/model.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* HIDAPI Coverity Scan model. See LICENSE.txt for licensing terms.
*
* Analysis input only: upload through Analysis Settings -> Modeling File.
* Never compile or link this file into HIDAPI.
*
* Model the public handle factories, whose implementation varies by backend.
* Both return an owned handle or NULL. Keep hid_close() and all I/O, error,
* enumeration and synchronization functions derived from the captured source:
* their effects include backend-specific resources and borrowed storage.
*
* Uninitialized choices intentionally express nondeterminism in Coverity's
* modeling language. They are not executable C.
*
* Contracts: hidapi/hidapi.h (hid_open and hid_open_path).
* Primitives: https://scan.coverity.com/models
*/

/* Scan compiles this file without system headers. The serial is never read,
* so its platform-specific character width does not affect this C model. */
typedef int wchar_t;

typedef struct hid_device_ hid_device;

void *__coverity_alloc_nosize__(void);
void __coverity_writeall__(void *ptr);

hid_device *hid_open(unsigned short vendor_id, unsigned short product_id,
const wchar_t *serial_number)
{
int succeeds;
hid_device *dev;
(void)vendor_id;
(void)product_id;
(void)serial_number;

if (!succeeds)
return 0;
dev = (hid_device *)__coverity_alloc_nosize__();
__coverity_writeall__(dev);
return dev;
}

hid_device *hid_open_path(const char *path)
{
int succeeds;
hid_device *dev;
(void)path;

if (!succeeds)
return 0;
dev = (hid_device *)__coverity_alloc_nosize__();
__coverity_writeall__(dev);
return dev;
}
96 changes: 96 additions & 0 deletions .coverity/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Set up Coverity Scan capture tools
description: Download or restore an unconfigured native Scan tool installation.
inputs:
platform:
description: Scan download platform (win64, Darwin, or linux64)
required: true
token:
description: Coverity Scan project token
required: true
runs:
using: composite
steps:
- name: Look up package hash
id: package
shell: bash
env:
COVERITY_PLATFORM: ${{ inputs.platform }}
COVERITY_SCAN_TOKEN: ${{ inputs.token }}
run: |
case "$COVERITY_PLATFORM" in win64|Darwin|linux64) ;; *) exit 1 ;; esac
hash=$(curl --fail --silent --show-error --connect-timeout 30 --max-time 120 \
--data-urlencode "token=$COVERITY_SCAN_TOKEN" --data project=hidapi --data md5=1 \
"https://scan.coverity.com/download/cxx/$COVERITY_PLATFORM")
[[ "$hash" =~ ^[[:xdigit:]]{32}$ ]] || { echo 'Invalid package hash'; exit 1; }
echo "hash=$hash" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
id: cache
with:
path: cov-root
key: coverity-native-${{ inputs.platform }}-${{ steps.package.outputs.hash }}
- name: Download tools
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
env:
COVERITY_PLATFORM: ${{ inputs.platform }}
COVERITY_SCAN_TOKEN: ${{ inputs.token }}
COVERITY_PACKAGE_HASH: ${{ steps.package.outputs.hash }}
run: |
curl --fail --silent --show-error --location --connect-timeout 30 --max-time 540 \
--data-urlencode "token=$COVERITY_SCAN_TOKEN" --data project=hidapi \
--output coverity-package "https://scan.coverity.com/download/cxx/$COVERITY_PLATFORM"
if [[ "$COVERITY_PLATFORM" == Darwin ]]; then
actual_hash=$(md5 -q coverity-package)
else
actual_hash=$(md5sum coverity-package | cut -d ' ' -f 1)
fi
[[ "$actual_hash" == "$COVERITY_PACKAGE_HASH" ]] || { echo 'Package checksum mismatch'; exit 1; }
- name: Extract Windows tools
if: steps.cache.outputs.cache-hit != 'true' && inputs.platform == 'win64'
shell: pwsh
run: |
Move-Item -LiteralPath coverity-package -Destination coverity-package.zip
Expand-Archive -LiteralPath coverity-package.zip -DestinationPath cov-root
- name: Extract Linux tools
if: steps.cache.outputs.cache-hit != 'true' && inputs.platform == 'linux64'
shell: bash
run: |
mkdir cov-root
timeout --kill-after=3s 300s tar -xzf coverity-package --strip-components=1 -C cov-root
- name: Extract macOS tools
if: steps.cache.outputs.cache-hit != 'true' && inputs.platform == 'Darwin'
shell: bash
run: |
mv coverity-package coverity-package.dmg
mkdir coverity
hdiutil attach coverity-package.dmg -mountroot coverity
package_dirs=(coverity/*)
package_dir="${package_dirs[0]}"
installer="$package_dir/$(basename "$package_dir").sh"
mkdir cov-root
installer_name=$(basename "$installer")
cp "$installer" "cov-root/$installer_name"
chmod +x "cov-root/$installer_name"
(cd cov-root && "./$installer_name")
# Some packages remove their installer after extraction.
rm -f "cov-root/$installer_name"
hdiutil detach "$package_dir"
- name: Add tools to PATH
shell: bash
env:
COVERITY_PLATFORM: ${{ inputs.platform }}
run: |
if [[ "$COVERITY_PLATFORM" == win64 ]]; then
tool_dirs=("$PWD"/cov-root/*/bin)
cygpath -w "${tool_dirs[0]}" >> "$GITHUB_PATH"
else
echo "$PWD/cov-root/bin" >> "$GITHUB_PATH"
fi
- name: Verify tool installation
shell: bash
run: cov-manage-emit --ident
- uses: actions/cache/save@v4
if: steps.cache.outputs.cache-hit != 'true'
with:
path: cov-root
key: ${{ steps.cache.outputs.cache-primary-key }}
115 changes: 115 additions & 0 deletions .coverity/test_merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Validate submission gates without running or uploading an analysis."""

import contextlib
import io
import pathlib
import tempfile
import unittest
from unittest.mock import patch

import merge


VERSION = "version 2024.12.1 on test\nInternal version numbers: test-build\n"
SOURCES = {
"windows": ["C:/src/windows/hid.c", "C:/src/hidtest/test.c"],
"macos": ["/mac/src/mac/hid.c"],
"linux": ["/linux/src/linux/hid.c", "/linux/src/libusb/hid.c",
"/linux/src/hidtest/test.c", "/linux/src/hidtest/test.c"],
}


class MergeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = pathlib.Path(self.temp.name)
self.captures = self.root / "captures"
self.target = self.root / "combined"
self.sources = {key: value.copy() for key, value in SOURCES.items()}
self.calls = []
self.drop_duplicate = False
for platform in SOURCES:
capture = self.captures / f"coverity-{platform}"
(capture / "cov-int").mkdir(parents=True)
(capture / "coverity-version.txt").write_text(VERSION)

def fake_cov(self, *args):
self.calls.append(args)
if args == ("--ident",):
return VERSION
idir, command = args[1:3]
if command == "list":
if idir == self.target:
paths = [path for values in self.sources.values() for path in values]
if self.drop_duplicate:
paths = list(dict.fromkeys(paths))
else:
paths = self.sources[idir.parent.name.removeprefix("coverity-")]
return "".join(f"Translation unit:\n{i} -> {path}\n" for i, path in enumerate(paths, 1))
return ""

def run_merge(self):
with patch.object(merge, "cov", self.fake_cov), \
patch.object(pathlib.Path, "write_text") as report, \
contextlib.redirect_stdout(io.StringIO()):
merge.merge(self.captures, self.target)
return report.call_args.args[0]

def test_preserves_repeated_sources(self):
report = self.run_merge()
self.assertIn('"merged": 7', report)
self.assertEqual(sum(call[2] == "add" for call in self.calls if len(call) > 2), 2)

def test_rejects_version_mismatch(self):
(self.captures / "coverity-macos/coverity-version.txt").write_text(
VERSION.replace("test-build", "other-build"))
with self.assertRaisesRegex(RuntimeError, "version mismatch"):
self.run_merge()

def test_rejects_empty_capture(self):
self.sources["windows"] = []
with self.assertRaisesRegex(RuntimeError, "Empty or incomplete"):
self.run_merge()

def test_rejects_missing_backend(self):
self.sources["linux"].remove("/linux/src/libusb/hid.c")
with self.assertRaisesRegex(RuntimeError, "Missing backend"):
self.run_merge()

def test_rejects_recoverable_errors(self):
self.sources["linux"][1] += " (recoverable errors)"
with self.assertRaisesRegex(RuntimeError, "Empty or incomplete"):
self.run_merge()

def test_rejects_lost_duplicate(self):
self.drop_duplicate = True
with self.assertRaisesRegex(RuntimeError, "differ"):
self.run_merge()

def test_rejects_invalid_version(self):
with self.assertRaisesRegex(ValueError, "version output"):
merge.version("unrecognized output")

def test_accepts_tool_prefixed_version(self):
self.assertEqual(merge.version("Coverity tool " + VERSION), merge.version(VERSION))

def test_accepts_windows_text(self):
(self.captures / "coverity-windows/coverity-version.txt").write_bytes(
("\ufeff" + VERSION.replace("\n", "\r\n")).encode("utf-8"))
self.sources["windows"] = [path.replace("/", "\\") for path in SOURCES["windows"]]
self.assertIn('"merged": 7', self.run_merge())

def test_rejects_unrecoverable_errors(self):
self.sources["linux"][1] += " (unrecoverable errors)"
with self.assertRaisesRegex(RuntimeError, "Empty or incomplete"):
self.run_merge()

def test_rejects_missing_metadata(self):
(self.captures / "coverity-macos/coverity-version.txt").unlink()
with self.assertRaises(FileNotFoundError):
self.run_merge()


if __name__ == "__main__":
unittest.main()
Loading
Loading