pompelmi 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BADGE.md CHANGED
@@ -5,25 +5,25 @@ Add this badge to your repository's `README.md` to show that your file uploads a
5
5
  ## Markdown
6
6
 
7
7
  ```markdown
8
- [![Scanned by pompelmi](https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github)](https://github.com/pompelmi/pompelmi)
8
+ [![Scanned by pompelmi](https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAQxlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAA5AAAAcgE7AAIAAAASAAAArIdpAAQAAAABAAAAvgAAAAAAAABgAAAAAQAAAGAAAAABQ2FudmEgZG9jPURBSEdqUE42M19JIHVzZXI9VUFHZVZYTlJxNEkgYnJhbmQ9QkFHZVZib2RxREkAAFRvbW1hc28gQmVydG9jY2hpAAAGkAAABwAAAAQwMjEwkQEABwAAAAQBAgMAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAOoAMABAAAAAEAAAAOAAAAAOn+IX8AAAAJcEhZcwAADsQAAA7EAZUrDhsAAAZHaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgICAgICAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjY1NTM1PC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4xNTAwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6RXhpZlZlcnNpb24+MDIxMDwvZXhpZjpFeGlmVmVyc2lvbj4KICAgICAgICAgPGV4aWY6Rmxhc2hQaXhWZXJzaW9uPjAxMDA8L2V4aWY6Rmxhc2hQaXhWZXJzaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MTUwMDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjM8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaT4wPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkNhbnZhIGRvYz1EQUhHalBONjNfSSB1c2VyPVVBR2VWWE5ScTRJIGJyYW5kPUJBR2VWYm9kcURJPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjk2PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj45NjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPGRjOnRpdGxlPgogICAgICAgICAgICA8cmRmOkFsdD4KICAgICAgICAgICAgICAgPHJkZjpsaSB4bWw6bGFuZz0ieC1kZWZhdWx0Ij5Qcm9nZXR0byBzZW56YSB0aXRvbG8gLSAxPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOkFsdD4KICAgICAgICAgPC9kYzp0aXRsZT4KICAgICAgICAgPGRjOmNyZWF0b3I+CiAgICAgICAgICAgIDxyZGY6U2VxPgogICAgICAgICAgICAgICA8cmRmOmxpPlRvbW1hc28gQmVydG9jY2hpPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9kYzpjcmVhdG9yPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kn4j/DQAAAeFJREFUKBWtUjtrFFEU/u68Z5zs7LLPhEgEG0ljETGFImgnNhKsYiEBLYU0KqiERaxEQbcRK8HCiH/AUrFVEEQQ0YiurvvIRp2d2ZnZeezJzIZZSAxi4S3uOffc73zfOede4H8vImJ/4/zj8t7Tc3MdixaYJ70RcsaL6uKd7m4E3M6g6TgrduBf7TQbF463zQX//rXDOzHJeZvixdrJTC7a89hreqdO62VntlzUZD+MAsd96HDqcuXS7X5KIqROYi8fWgom1nsFcX4SimFoYbeF4P1bXhUH54Mw+BZDbqT4can0HEIh7C4rhdx8xMtwbRv+18/wLROm08dgODz7sVaT08SRYjJB/+Wjm+KHzpWNxiswXQE/sxcYAoHVBjERJHIZs/NOiRMHSfKW4u+7hr9uLXk/XUCSwHEcqNWCVNkHafYIpHwJvKquTZ84GqaKW4nZZuS7Zhj0TbC8AfBirJqBV6+DYi7SJ6GVZuaMbOkZ/XitjRUZu2UNNXFVmVBAn9bANroQBA5CpQyKeyTPhZTVFebZByAbo/bGU6XWl2qvOE2cyo6xYtaPdPaEK04dVGR5kTxbo1+dJvrSCsuf6SWK295xVD/FMQYa+fHWfnB9v+4MDNP8Xp+qru76i1LsP9lNSkO4P3HUKYoAAAAASUVORK5CYII=)](https://github.com/pompelmi/pompelmi)
9
9
  ```
10
10
 
11
11
  ## HTML
12
12
 
13
13
  ```html
14
14
  <a href="https://github.com/pompelmi/pompelmi">
15
- <img src="https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github" alt="Scanned by pompelmi">
15
+ <img src="https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAQxlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAA5AAAAcgE7AAIAAAASAAAArIdpAAQAAAABAAAAvgAAAAAAAABgAAAAAQAAAGAAAAABQ2FudmEgZG9jPURBSEdqUE42M19JIHVzZXI9VUFHZVZYTlJxNEkgYnJhbmQ9QkFHZVZib2RxREkAAFRvbW1hc28gQmVydG9jY2hpAAAGkAAABwAAAAQwMjEwkQEABwAAAAQBAgMAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAOoAMABAAAAAEAAAAOAAAAAOn+IX8AAAAJcEhZcwAADsQAAA7EAZUrDhsAAAZHaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgICAgICAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjY1NTM1PC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4xNTAwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6RXhpZlZlcnNpb24+MDIxMDwvZXhpZjpFeGlmVmVyc2lvbj4KICAgICAgICAgPGV4aWY6Rmxhc2hQaXhWZXJzaW9uPjAxMDA8L2V4aWY6Rmxhc2hQaXhWZXJzaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MTUwMDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjM8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaT4wPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkNhbnZhIGRvYz1EQUhHalBONjNfSSB1c2VyPVVBR2VWWE5ScTRJIGJyYW5kPUJBR2VWYm9kcURJPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjk2PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj45NjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPGRjOnRpdGxlPgogICAgICAgICAgICA8cmRmOkFsdD4KICAgICAgICAgICAgICAgPHJkZjpsaSB4bWw6bGFuZz0ieC1kZWZhdWx0Ij5Qcm9nZXR0byBzZW56YSB0aXRvbG8gLSAxPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOkFsdD4KICAgICAgICAgPC9kYzp0aXRsZT4KICAgICAgICAgPGRjOmNyZWF0b3I+CiAgICAgICAgICAgIDxyZGY6U2VxPgogICAgICAgICAgICAgICA8cmRmOmxpPlRvbW1hc28gQmVydG9jY2hpPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9kYzpjcmVhdG9yPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kn4j/DQAAAeFJREFUKBWtUjtrFFEU/u68Z5zs7LLPhEgEG0ljETGFImgnNhKsYiEBLYU0KqiERaxEQbcRK8HCiH/AUrFVEEQQ0YiurvvIRp2d2ZnZeezJzIZZSAxi4S3uOffc73zfOede4H8vImJ/4/zj8t7Tc3MdixaYJ70RcsaL6uKd7m4E3M6g6TgrduBf7TQbF463zQX//rXDOzHJeZvixdrJTC7a89hreqdO62VntlzUZD+MAsd96HDqcuXS7X5KIqROYi8fWgom1nsFcX4SimFoYbeF4P1bXhUH54Mw+BZDbqT4can0HEIh7C4rhdx8xMtwbRv+18/wLROm08dgODz7sVaT08SRYjJB/+Wjm+KHzpWNxiswXQE/sxcYAoHVBjERJHIZs/NOiRMHSfKW4u+7hr9uLXk/XUCSwHEcqNWCVNkHafYIpHwJvKquTZ84GqaKW4nZZuS7Zhj0TbC8AfBirJqBV6+DYi7SJ6GVZuaMbOkZ/XitjRUZu2UNNXFVmVBAn9bANroQBA5CpQyKeyTPhZTVFebZByAbo/bGU6XWl2qvOE2cyo6xYtaPdPaEK04dVGR5kTxbo1+dJvrSCsuf6SWK295xVD/FMQYa+fHWfnB9v+4MDNP8Xp+qru76i1LsP9lNSkO4P3HUKYoAAAAASUVORK5CYII=" alt="Scanned by pompelmi">
16
16
  </a>
17
17
  ```
18
18
 
19
19
  ## RST (reStructuredText)
20
20
 
21
21
  ```rst
22
- .. image:: https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github
22
+ .. image:: https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAQxlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAA5AAAAcgE7AAIAAAASAAAArIdpAAQAAAABAAAAvgAAAAAAAABgAAAAAQAAAGAAAAABQ2FudmEgZG9jPURBSEdqUE42M19JIHVzZXI9VUFHZVZYTlJxNEkgYnJhbmQ9QkFHZVZib2RxREkAAFRvbW1hc28gQmVydG9jY2hpAAAGkAAABwAAAAQwMjEwkQEABwAAAAQBAgMAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAOoAMABAAAAAEAAAAOAAAAAOn+IX8AAAAJcEhZcwAADsQAAA7EAZUrDhsAAAZHaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgICAgICAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjY1NTM1PC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4xNTAwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6RXhpZlZlcnNpb24+MDIxMDwvZXhpZjpFeGlmVmVyc2lvbj4KICAgICAgICAgPGV4aWY6Rmxhc2hQaXhWZXJzaW9uPjAxMDA8L2V4aWY6Rmxhc2hQaXhWZXJzaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MTUwMDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjM8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaT4wPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkNhbnZhIGRvYz1EQUhHalBONjNfSSB1c2VyPVVBR2VWWE5ScTRJIGJyYW5kPUJBR2VWYm9kcURJPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjk2PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj45NjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPGRjOnRpdGxlPgogICAgICAgICAgICA8cmRmOkFsdD4KICAgICAgICAgICAgICAgPHJkZjpsaSB4bWw6bGFuZz0ieC1kZWZhdWx0Ij5Qcm9nZXR0byBzZW56YSB0aXRvbG8gLSAxPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOkFsdD4KICAgICAgICAgPC9kYzp0aXRsZT4KICAgICAgICAgPGRjOmNyZWF0b3I+CiAgICAgICAgICAgIDxyZGY6U2VxPgogICAgICAgICAgICAgICA8cmRmOmxpPlRvbW1hc28gQmVydG9jY2hpPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9kYzpjcmVhdG9yPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kn4j/DQAAAeFJREFUKBWtUjtrFFEU/u68Z5zs7LLPhEgEG0ljETGFImgnNhKsYiEBLYU0KqiERaxEQbcRK8HCiH/AUrFVEEQQ0YiurvvIRp2d2ZnZeezJzIZZSAxi4S3uOffc73zfOede4H8vImJ/4/zj8t7Tc3MdixaYJ70RcsaL6uKd7m4E3M6g6TgrduBf7TQbF463zQX//rXDOzHJeZvixdrJTC7a89hreqdO62VntlzUZD+MAsd96HDqcuXS7X5KIqROYi8fWgom1nsFcX4SimFoYbeF4P1bXhUH54Mw+BZDbqT4can0HEIh7C4rhdx8xMtwbRv+18/wLROm08dgODz7sVaT08SRYjJB/+Wjm+KHzpWNxiswXQE/sxcYAoHVBjERJHIZs/NOiRMHSfKW4u+7hr9uLXk/XUCSwHEcqNWCVNkHafYIpHwJvKquTZ84GqaKW4nZZuS7Zhj0TbC8AfBirJqBV6+DYi7SJ6GVZuaMbOkZ/XitjRUZu2UNNXFVmVBAn9bANroQBA5CpQyKeyTPhZTVFebZByAbo/bGU6XWl2qvOE2cyo6xYtaPdPaEK04dVGR5kTxbo1+dJvrSCsuf6SWK295xVD/FMQYa+fHWfnB9v+4MDNP8Xp+qru76i1LsP9lNSkO4P3HUKYoAAAAASUVORK5CYII=
23
23
  :target: https://github.com/pompelmi/pompelmi
24
24
  :alt: Scanned by pompelmi
25
25
  ```
26
26
 
27
27
  ## Preview
28
28
 
29
- [![Scanned by pompelmi](https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github)](https://github.com/pompelmi/pompelmi)
29
+ [![Scanned by pompelmi](https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAQxlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAA5AAAAcgE7AAIAAAASAAAArIdpAAQAAAABAAAAvgAAAAAAAABgAAAAAQAAAGAAAAABQ2FudmEgZG9jPURBSEdqUE42M19JIHVzZXI9VUFHZVZYTlJxNEkgYnJhbmQ9QkFHZVZib2RxREkAAFRvbW1hc28gQmVydG9jY2hpAAAGkAAABwAAAAQwMjEwkQEABwAAAAQBAgMAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAOoAMABAAAAAEAAAAOAAAAAOn+IX8AAAAJcEhZcwAADsQAAA7EAZUrDhsAAAZHaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgICAgICAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjY1NTM1PC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4xNTAwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6RXhpZlZlcnNpb24+MDIxMDwvZXhpZjpFeGlmVmVyc2lvbj4KICAgICAgICAgPGV4aWY6Rmxhc2hQaXhWZXJzaW9uPjAxMDA8L2V4aWY6Rmxhc2hQaXhWZXJzaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MTUwMDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjM8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaT4wPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkNhbnZhIGRvYz1EQUhHalBONjNfSSB1c2VyPVVBR2VWWE5ScTRJIGJyYW5kPUJBR2VWYm9kcURJPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjk2PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj45NjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPGRjOnRpdGxlPgogICAgICAgICAgICA8cmRmOkFsdD4KICAgICAgICAgICAgICAgPHJkZjpsaSB4bWw6bGFuZz0ieC1kZWZhdWx0Ij5Qcm9nZXR0byBzZW56YSB0aXRvbG8gLSAxPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOkFsdD4KICAgICAgICAgPC9kYzp0aXRsZT4KICAgICAgICAgPGRjOmNyZWF0b3I+CiAgICAgICAgICAgIDxyZGY6U2VxPgogICAgICAgICAgICAgICA8cmRmOmxpPlRvbW1hc28gQmVydG9jY2hpPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9kYzpjcmVhdG9yPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kn4j/DQAAAeFJREFUKBWtUjtrFFEU/u68Z5zs7LLPhEgEG0ljETGFImgnNhKsYiEBLYU0KqiERaxEQbcRK8HCiH/AUrFVEEQQ0YiurvvIRp2d2ZnZeezJzIZZSAxi4S3uOffc73zfOede4H8vImJ/4/zj8t7Tc3MdixaYJ70RcsaL6uKd7m4E3M6g6TgrduBf7TQbF463zQX//rXDOzHJeZvixdrJTC7a89hreqdO62VntlzUZD+MAsd96HDqcuXS7X5KIqROYi8fWgom1nsFcX4SimFoYbeF4P1bXhUH54Mw+BZDbqT4can0HEIh7C4rhdx8xMtwbRv+18/wLROm08dgODz7sVaT08SRYjJB/+Wjm+KHzpWNxiswXQE/sxcYAoHVBjERJHIZs/NOiRMHSfKW4u+7hr9uLXk/XUCSwHEcqNWCVNkHafYIpHwJvKquTZ84GqaKW4nZZuS7Zhj0TbC8AfBirJqBV6+DYi7SJ6GVZuaMbOkZ/XitjRUZu2UNNXFVmVBAn9bANroQBA5CpQyKeyTPhZTVFebZByAbo/bGU6XWl2qvOE2cyo6xYtaPdPaEK04dVGR5kTxbo1+dJvrSCsuf6SWK295xVD/FMQYa+fHWfnB9v+4MDNP8Xp+qru76i1LsP9lNSkO4P3HUKYoAAAAASUVORK5CYII=)](https://github.com/pompelmi/pompelmi)
package/README.md CHANGED
@@ -15,7 +15,8 @@
15
15
  <img src="https://img.shields.io/badge/license-ISC-blue" alt="license">
16
16
  <a href="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml"><img src="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
17
17
  <a href="https://github.com/pompelmi/pompelmi/actions/workflows/release.yml"><img src="https://github.com/pompelmi/pompelmi/actions/workflows/release.yml/badge.svg" alt="npm publish"></a>
18
- <a href="https://github.com/pompelmi/pompelmi"><img src="https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github" alt="Scanned by pompelmi"></a>
18
+ <img src="https://img.shields.io/badge/TypeScript-types%20included-3178c6?logo=typescript&logoColor=white" alt="TypeScript types included">
19
+ <a href="https://github.com/pompelmi/pompelmi"><img src="https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAQxlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAA5AAAAcgE7AAIAAAASAAAArIdpAAQAAAABAAAAvgAAAAAAAABgAAAAAQAAAGAAAAABQ2FudmEgZG9jPURBSEdqUE42M19JIHVzZXI9VUFHZVZYTlJxNEkgYnJhbmQ9QkFHZVZib2RxREkAAFRvbW1hc28gQmVydG9jY2hpAAAGkAAABwAAAAQwMjEwkQEABwAAAAQBAgMAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAOoAMABAAAAAEAAAAOAAAAAOn+IX8AAAAJcEhZcwAADsQAAA7EAZUrDhsAAAZHaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgICAgICAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjY1NTM1PC9leGlmOkNvbG9yU3BhY2U+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4xNTAwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6RXhpZlZlcnNpb24+MDIxMDwvZXhpZjpFeGlmVmVyc2lvbj4KICAgICAgICAgPGV4aWY6Rmxhc2hQaXhWZXJzaW9uPjAxMDA8L2V4aWY6Rmxhc2hQaXhWZXJzaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MTUwMDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaT4xPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGk+MjwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpPjM8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaT4wPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9leGlmOkNvbXBvbmVudHNDb25maWd1cmF0aW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkNhbnZhIGRvYz1EQUhHalBONjNfSSB1c2VyPVVBR2VWWE5ScTRJIGJyYW5kPUJBR2VWYm9kcURJPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjk2PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj45NjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPGRjOnRpdGxlPgogICAgICAgICAgICA8cmRmOkFsdD4KICAgICAgICAgICAgICAgPHJkZjpsaSB4bWw6bGFuZz0ieC1kZWZhdWx0Ij5Qcm9nZXR0byBzZW56YSB0aXRvbG8gLSAxPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOkFsdD4KICAgICAgICAgPC9kYzp0aXRsZT4KICAgICAgICAgPGRjOmNyZWF0b3I+CiAgICAgICAgICAgIDxyZGY6U2VxPgogICAgICAgICAgICAgICA8cmRmOmxpPlRvbW1hc28gQmVydG9jY2hpPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC9kYzpjcmVhdG9yPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kn4j/DQAAAeFJREFUKBWtUjtrFFEU/u68Z5zs7LLPhEgEG0ljETGFImgnNhKsYiEBLYU0KqiERaxEQbcRK8HCiH/AUrFVEEQQ0YiurvvIRp2d2ZnZeezJzIZZSAxi4S3uOffc73zfOede4H8vImJ/4/zj8t7Tc3MdixaYJ70RcsaL6uKd7m4E3M6g6TgrduBf7TQbF463zQX//rXDOzHJeZvixdrJTC7a89hreqdO62VntlzUZD+MAsd96HDqcuXS7X5KIqROYi8fWgom1nsFcX4SimFoYbeF4P1bXhUH54Mw+BZDbqT4can0HEIh7C4rhdx8xMtwbRv+18/wLROm08dgODz7sVaT08SRYjJB/+Wjm+KHzpWNxiswXQE/sxcYAoHVBjERJHIZs/NOiRMHSfKW4u+7hr9uLXk/XUCSwHEcqNWCVNkHafYIpHwJvKquTZ84GqaKW4nZZuS7Zhj0TbC8AfBirJqBV6+DYi7SJ6GVZuaMbOkZ/XitjRUZu2UNNXFVmVBAn9bANroQBA5CpQyKeyTPhZTVFebZByAbo/bGU6XWl2qvOE2cyo6xYtaPdPaEK04dVGR5kTxbo1+dJvrSCsuf6SWK295xVD/FMQYa+fHWfnB9v+4MDNP8Xp+qru76i1LsP9lNSkO4P3HUKYoAAAAASUVORK5CYII=" alt="Scanned by pompelmi"></a>
19
20
  </p>
20
21
 
21
22
  ---
@@ -26,6 +27,7 @@
26
27
  |-------|-------------|
27
28
  | [Getting Started](./docs/getting-started.md) | Installation, prerequisites, quickstart examples |
28
29
  | [API Reference](./docs/api.md) | Full function signatures, options, verdicts, error conditions |
30
+ | [S3 Integration](./docs/s3.md) | Scan S3 objects directly, IAM setup, Lambda pattern |
29
31
  | [Docker / Remote Scanning](./docs/docker.md) | TCP sidecar, UNIX socket mount, docker-compose patterns |
30
32
  | [GitHub Action](./docs/github-action.md) | CI scanning, inputs/outputs, caching, example workflows |
31
33
 
@@ -58,6 +60,10 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
58
60
  - `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
59
61
  - `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
60
62
  - `scanDirectory(dirPath, [options])` — recursively scan every file in a directory, returns clean/malicious/errors arrays
63
+ - `scanS3(params, [options])` — scan S3 objects by streaming directly from AWS S3, no disk I/O
64
+ - `createPool([options])` — persistent connection pool for high-throughput clamd scanning
65
+ - `watch(dirPath, [options], callbacks)` — watch a directory and auto-scan new/modified files (300 ms debounce)
66
+ - Auto-retry on connection error — `retries` and `retryDelay` options on every scan function
61
67
  - Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
62
68
  - Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
63
69
  - Built-in helpers to install ClamAV and update virus definitions programmatically
@@ -295,12 +301,14 @@ See **[docs/docker.md](./docs/docker.md)** for Docker Compose examples, UNIX soc
295
301
 
296
302
  pompelmi has no configuration file or environment variables. All options are passed directly to `scan()`.
297
303
 
298
- | Option | Type | Default | Description |
299
- |-----------|----------|-----------------|----------------------------------------|
300
- | `socket` | `string` | — | Path to a clamd UNIX domain socket (e.g. `/run/clamav/clamd.sock`). Takes precedence over `host`/`port` when set. |
301
- | `host` | `string` | — | clamd hostname. Enables TCP mode when set. |
302
- | `port` | `number` | `3310` | clamd port. |
303
- | `timeout` | `number` | `15000` | Socket idle timeout in milliseconds (clamd mode only). |
304
+ | Option | Type | Default | Description |
305
+ |--------------|----------|---------|----------------------------------------|
306
+ | `socket` | `string` | — | Path to a clamd UNIX domain socket (e.g. `/run/clamav/clamd.sock`). Takes precedence over `host`/`port` when set. |
307
+ | `host` | `string` | — | clamd hostname. Enables TCP mode when set. |
308
+ | `port` | `number` | `3310` | clamd port. |
309
+ | `timeout` | `number` | `15000` | Socket idle timeout in milliseconds (clamd mode only). |
310
+ | `retries` | `number` | `0` | Automatic retry attempts on connection error. |
311
+ | `retryDelay` | `number` | `1000` | Milliseconds to wait between retries. |
304
312
 
305
313
  When none of `socket`, `host`, or `port` is provided, pompelmi spawns `clamscan --no-summary <filePath>` locally.
306
314
 
@@ -312,12 +320,15 @@ See **[docs/api.md](./docs/api.md)** for the full reference: function signatures
312
320
 
313
321
  **Quick summary:**
314
322
 
315
- | Function | Input | clamd mode disk I/O |
316
- |----------|-------|---------------------|
317
- | `scan(filePath, [options])` | File path on disk | None (streamed) |
323
+ | Function | Input | Disk I/O |
324
+ |----------|-------|----------|
325
+ | `scan(filePath, [options])` | File path on disk | None in clamd mode (streamed) |
318
326
  | `scanBuffer(buffer, [options])` | `Buffer` | None (streamed) |
319
327
  | `scanStream(stream, [options])` | Node.js `Readable` | None (streamed) |
320
- | `scanDirectory(dirPath, [options])` | Directory path | None (streamed) |
328
+ | `scanDirectory(dirPath, [options])` | Directory path | None in clamd mode |
329
+ | `scanS3(params, [options])` | S3 bucket + key | None (streamed from S3) |
330
+ | `createPool([options])` | — | Returns a `ClamdPool` |
331
+ | `watch(dirPath, [options], callbacks)` | Directory path | None in clamd mode |
321
332
 
322
333
  All four functions accept the same `options` object and resolve to the same three verdict Symbols:
323
334
 
@@ -346,7 +357,19 @@ choco install clamav -y
346
357
 
347
358
  ## Examples
348
359
 
349
- The [`examples/`](./examples/) directory contains standalone runnable scripts. Each can be run with `node examples/<name>.js`.
360
+ The [`examples/`](./examples/) directory contains standalone runnable scripts and framework-specific starters.
361
+
362
+ ### Framework starters
363
+
364
+ | Directory | Description |
365
+ |-----------|-------------|
366
+ | [`examples/express/`](./examples/express/) | Full Express app with multer + pompelmi middleware |
367
+ | [`examples/nextjs/`](./examples/nextjs/) | Next.js API route that scans raw upload bytes |
368
+ | [`examples/nestjs/`](./examples/nestjs/) | NestJS guard wrapping pompelmi for route-level protection |
369
+
370
+ ### Standalone scripts
371
+
372
+ Each can be run with `node examples/<name>.js`.
350
373
 
351
374
  | File | Description |
352
375
  |------|-------------|
@@ -370,7 +393,7 @@ The [`examples/`](./examples/) directory contains standalone runnable scripts. E
370
393
  | `scan-zip.js` | ZIP archive scan (ClamAV recurses automatically) |
371
394
  | `install-clamav.js` | Programmatic ClamAV installation |
372
395
  | `update-virus-database.js` | Programmatic virus DB update |
373
- | `typescript-usage.ts` | TypeScript example with inline type declarations |
396
+ | `typescript-usage.ts` | TypeScript example with full type declarations |
374
397
 
375
398
  ---
376
399
 
@@ -412,6 +435,7 @@ Scan any repository for viruses on every push or pull request — ClamAV is bund
412
435
  |-------|-------------|---------|
413
436
  | `path` | Directory or file to scan | `.` (full workspace) |
414
437
  | `fail-on-virus` | Fail the workflow step when infected files are found | `true` |
438
+ | `comment-on-pr` | Post a PR comment listing infected files (requires `GITHUB_TOKEN`) | `true` |
415
439
 
416
440
  ### Outputs
417
441
 
@@ -457,6 +481,13 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
457
481
 
458
482
  ---
459
483
 
484
+ ## Coming soon
485
+
486
+ - [ ] Cloudflare Workers support — edge-native scanning via the clamd TCP protocol
487
+ - [ ] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
488
+
489
+ ---
490
+
460
491
  ## License
461
492
 
462
493
  [ISC](./LICENSE) — © pompelmi contributors
package/action/scanner.js CHANGED
@@ -6,6 +6,7 @@ const { scan, scanDirectory, Verdict } = require('pompelmi');
6
6
 
7
7
  const scanPath = process.argv[2] || '.';
8
8
  const failOnVirus = process.argv[3] !== 'false';
9
+ const commentOnPr = process.argv[4] !== 'false';
9
10
 
10
11
  async function writeReport(clean, malicious, errors, outputDir) {
11
12
  const total = clean.length + malicious.length + errors.length;
@@ -67,6 +68,60 @@ function escHtml(s) {
67
68
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
68
69
  }
69
70
 
71
+ async function postPrComment(malicious) {
72
+ if (!commentOnPr) return;
73
+
74
+ const token = process.env.GITHUB_TOKEN;
75
+ const eventName = process.env.GITHUB_EVENT_NAME;
76
+ if (!token || eventName !== 'pull_request') return;
77
+
78
+ const eventPath = process.env.GITHUB_EVENT_PATH;
79
+ if (!eventPath || !fs.existsSync(eventPath)) return;
80
+
81
+ let event;
82
+ try { event = JSON.parse(fs.readFileSync(eventPath, 'utf8')); }
83
+ catch { return; }
84
+
85
+ const prNumber = event.pull_request && event.pull_request.number;
86
+ const repo = process.env.GITHUB_REPOSITORY;
87
+ if (!prNumber || !repo) return;
88
+
89
+ const rows = malicious
90
+ .map(f => `| \`${escHtml(f)}\` | Malicious |`)
91
+ .join('\n');
92
+ const body =
93
+ `## ❌ Pompelmi: Virus Detected\n\n` +
94
+ `The following infected file(s) were found during the scan:\n\n` +
95
+ `| File | Verdict |\n|------|--------|\n${rows}\n\n` +
96
+ `> Scanned by [pompelmi](https://pompelmi.app)`;
97
+
98
+ const https = require('https');
99
+ const payload = JSON.stringify({ body });
100
+
101
+ await new Promise((resolve, reject) => {
102
+ const req = https.request({
103
+ hostname: 'api.github.com',
104
+ path: `/repos/${repo}/issues/${prNumber}/comments`,
105
+ method: 'POST',
106
+ headers: {
107
+ 'Authorization': `Bearer ${token}`,
108
+ 'Content-Type': 'application/json',
109
+ 'Content-Length': Buffer.byteLength(payload),
110
+ 'User-Agent': 'pompelmi-action',
111
+ 'Accept': 'application/vnd.github+json',
112
+ 'X-GitHub-Api-Version': '2022-11-28',
113
+ },
114
+ }, (res) => {
115
+ res.resume();
116
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve();
117
+ else reject(new Error(`GitHub API returned HTTP ${res.statusCode}`));
118
+ });
119
+ req.on('error', reject);
120
+ req.write(payload);
121
+ req.end();
122
+ }).catch(err => console.warn(`Could not post PR comment: ${err.message}`));
123
+ }
124
+
70
125
  async function uploadArtifact(jsonPath, htmlPath) {
71
126
  try {
72
127
  const { DefaultArtifactClient } = require('@actions/artifact');
@@ -152,6 +207,7 @@ async function main() {
152
207
  if (malicious.length > 0) {
153
208
  console.error('\nInfected files:');
154
209
  malicious.forEach(f => console.error(` ${f}`));
210
+ await postPrComment(malicious);
155
211
  if (failOnVirus) {
156
212
  console.error('\n::error::Virus(es) detected — failing workflow.');
157
213
  process.exit(1);
package/action.yml CHANGED
@@ -14,6 +14,10 @@ inputs:
14
14
  description: 'Fail the workflow step when infected files are found'
15
15
  required: false
16
16
  default: 'true'
17
+ comment-on-pr:
18
+ description: 'Post a PR comment listing infected files when a virus is found (requires GITHUB_TOKEN)'
19
+ required: false
20
+ default: 'true'
17
21
 
18
22
  outputs:
19
23
  infected-files:
@@ -27,3 +31,4 @@ runs:
27
31
  args:
28
32
  - ${{ inputs.path }}
29
33
  - ${{ inputs.fail-on-virus }}
34
+ - ${{ inputs.comment-on-pr }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -32,6 +32,7 @@
32
32
  ],
33
33
  "type": "commonjs",
34
34
  "main": "./src/index.js",
35
+ "types": "./types/index.d.ts",
35
36
  "scripts": {
36
37
  "test": "node --test test/unit.test.js && node test/scan.test.js",
37
38
  "lint": "eslint src/"
@@ -26,45 +26,59 @@ function parseClamdResponse(raw) {
26
26
  * @param {number} [options.timeout=15000]
27
27
  * @returns {Promise<symbol>}
28
28
  */
29
- function scanBufferViaClamd(buffer, { host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = {}) {
30
- return new Promise((resolve, reject) => {
31
- const connOpts = socketPath ? { path: socketPath } : { host, port };
32
- const conn = net.createConnection(connOpts);
33
- const chunks = [];
34
- let settled = false;
29
+ function scanBufferViaClamd(buffer, options = {}) {
30
+ const { retries = 0, retryDelay = 1000, host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = options;
35
31
 
36
- function settle(fn, value) {
37
- if (settled) return;
38
- settled = true;
39
- conn.destroy();
40
- fn(value);
41
- }
32
+ function attempt() {
33
+ return new Promise((resolve, reject) => {
34
+ const connOpts = socketPath ? { path: socketPath } : { host, port };
35
+ const conn = net.createConnection(connOpts);
36
+ const chunks = [];
37
+ let settled = false;
42
38
 
43
- conn.setTimeout(timeout);
44
- conn.on('timeout', () =>
45
- settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
46
- );
47
- conn.on('error', (err) => settle(reject, err));
48
- conn.on('data', (chunk) => chunks.push(chunk));
49
- conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
39
+ function settle(fn, value) {
40
+ if (settled) return;
41
+ settled = true;
42
+ conn.destroy();
43
+ fn(value);
44
+ }
50
45
 
51
- conn.on('connect', () => {
52
- conn.write(CLAMD_INSTREAM);
46
+ conn.setTimeout(timeout);
47
+ conn.on('timeout', () =>
48
+ settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
49
+ );
50
+ conn.on('error', (err) => settle(reject, err));
51
+ conn.on('data', (chunk) => chunks.push(chunk));
52
+ conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
53
53
 
54
- let offset = 0;
55
- while (offset < buffer.length) {
56
- const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
57
- const header = Buffer.allocUnsafe(4);
58
- header.writeUInt32BE(chunk.length, 0);
59
- conn.write(header);
60
- conn.write(chunk);
61
- offset += chunk.length;
62
- }
54
+ conn.on('connect', () => {
55
+ conn.write(CLAMD_INSTREAM);
56
+
57
+ let offset = 0;
58
+ while (offset < buffer.length) {
59
+ const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
60
+ const header = Buffer.allocUnsafe(4);
61
+ header.writeUInt32BE(chunk.length, 0);
62
+ conn.write(header);
63
+ conn.write(chunk);
64
+ offset += chunk.length;
65
+ }
63
66
 
64
- conn.write(Buffer.alloc(4)); // terminating zero-length chunk
65
- conn.end();
67
+ conn.write(Buffer.alloc(4)); // terminating zero-length chunk
68
+ conn.end();
69
+ });
66
70
  });
67
- });
71
+ }
72
+
73
+ function run(left) {
74
+ return attempt().catch(async (err) => {
75
+ if (left <= 0) throw err;
76
+ await new Promise(r => setTimeout(r, retryDelay));
77
+ return run(left - 1);
78
+ });
79
+ }
80
+
81
+ return run(retries);
68
82
  }
69
83
 
70
84
  module.exports = { scanBufferViaClamd };
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const fs = require('fs');
5
+ const { Verdict } = require('./verdicts.js');
6
+
7
+ const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
8
+ const CHUNK_SIZE = 64 * 1024;
9
+
10
+ function parseClamdResponse(raw) {
11
+ const text = raw.toString('utf8').replace(/\0/g, '').trim();
12
+ if (text === 'stream: OK') return Verdict.Clean;
13
+ if (text.endsWith(' FOUND')) return Verdict.Malicious;
14
+ return Verdict.ScanError;
15
+ }
16
+
17
+ // One INSTREAM scan on a persistent socket. Does NOT call sock.end() — connection stays open.
18
+ // Detects end-of-response via the null terminator sent by clamd (z-prefix protocol).
19
+ function scanOnSocket(sock, sendPayload) {
20
+ return new Promise((resolve, reject) => {
21
+ const chunks = [];
22
+ let done = false;
23
+
24
+ function settle(fn, val) {
25
+ if (done) return;
26
+ done = true;
27
+ sock.removeListener('data', onData);
28
+ sock.removeListener('end', onEnd);
29
+ sock.removeListener('error', onError);
30
+ fn(val);
31
+ }
32
+
33
+ function onData(chunk) {
34
+ chunks.push(chunk);
35
+ const buf = Buffer.concat(chunks);
36
+ if (buf.includes(0)) settle(resolve, parseClamdResponse(buf));
37
+ }
38
+ function onEnd() { settle(resolve, parseClamdResponse(Buffer.concat(chunks))); }
39
+ function onError(err) { settle(reject, err); }
40
+
41
+ sock.on('data', onData);
42
+ sock.on('end', onEnd);
43
+ sock.on('error', onError);
44
+
45
+ sock.write(CLAMD_INSTREAM);
46
+ sendPayload(sock, (err) => settle(reject, err));
47
+ });
48
+ }
49
+
50
+ function openConnection(connOpts, timeout) {
51
+ return new Promise((resolve, reject) => {
52
+ const sock = net.createConnection(connOpts);
53
+ let done = false;
54
+ function settle(err) {
55
+ if (done) return;
56
+ done = true;
57
+ if (err) { sock.destroy(); reject(err); }
58
+ else resolve(sock);
59
+ }
60
+ sock.setTimeout(timeout);
61
+ sock.on('timeout', () => settle(new Error(`clamd connect timed out after ${timeout}ms`)));
62
+ sock.once('error', (err) => settle(err));
63
+ sock.once('connect', () => { sock.setTimeout(0); settle(null); });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Create a pool of `size` persistent clamd connections.
69
+ *
70
+ * @param {object} [options]
71
+ * @param {string} [options.host='127.0.0.1']
72
+ * @param {number} [options.port=3310]
73
+ * @param {string} [options.socket] - UNIX socket path (takes precedence over host/port)
74
+ * @param {number} [options.size=5] - Maximum number of concurrent connections
75
+ * @param {number} [options.timeout=15000]
76
+ * @returns {{ scan, scanBuffer, scanStream, destroy }}
77
+ */
78
+ function createPool({ host = '127.0.0.1', port = 3310, socket: socketPath, size = 5, timeout = 15_000 } = {}) {
79
+ const connOpts = socketPath ? { path: socketPath } : { host, port };
80
+ const slots = Array.from({ length: size }, () => ({ socket: null, busy: false }));
81
+ const queue = [];
82
+
83
+ function dequeue(slot) {
84
+ slot.busy = false;
85
+ if (queue.length > 0) {
86
+ const { fn, resolve, reject } = queue.shift();
87
+ runOnSlot(slot, fn).then(resolve, reject);
88
+ }
89
+ }
90
+
91
+ async function ensureConnected(slot) {
92
+ if (slot.socket && !slot.socket.destroyed && slot.socket.readyState === 'open') return;
93
+ if (slot.socket && !slot.socket.destroyed) slot.socket.destroy();
94
+ slot.socket = await openConnection(connOpts, timeout);
95
+ const sock = slot.socket;
96
+ sock.on('error', () => { if (slot.socket === sock) slot.socket = null; });
97
+ }
98
+
99
+ async function runOnSlot(slot, fn) {
100
+ slot.busy = true;
101
+ try {
102
+ await ensureConnected(slot);
103
+ return await fn(slot.socket);
104
+ } catch (err) {
105
+ if (slot.socket) { slot.socket.destroy(); slot.socket = null; }
106
+ throw err;
107
+ } finally {
108
+ dequeue(slot);
109
+ }
110
+ }
111
+
112
+ function enqueue(fn) {
113
+ return new Promise((resolve, reject) => {
114
+ const free = slots.find(s => !s.busy);
115
+ if (free) {
116
+ runOnSlot(free, fn).then(resolve, reject);
117
+ } else {
118
+ queue.push({ fn, resolve, reject });
119
+ }
120
+ });
121
+ }
122
+
123
+ return {
124
+ scan(filePath) {
125
+ if (typeof filePath !== 'string')
126
+ return Promise.reject(new Error('filePath must be a string'));
127
+ if (!fs.existsSync(filePath))
128
+ return Promise.reject(new Error(`File not found: ${filePath}`));
129
+ return enqueue((sock) => scanOnSocket(sock, (s, onErr) => {
130
+ const stream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
131
+ stream.on('error', onErr);
132
+ stream.on('data', (chunk) => {
133
+ const hdr = Buffer.allocUnsafe(4);
134
+ hdr.writeUInt32BE(chunk.length, 0);
135
+ s.write(hdr);
136
+ s.write(chunk);
137
+ });
138
+ stream.on('end', () => s.write(Buffer.alloc(4)));
139
+ }));
140
+ },
141
+
142
+ scanBuffer(buffer) {
143
+ return enqueue((sock) => scanOnSocket(sock, (s) => {
144
+ let offset = 0;
145
+ while (offset < buffer.length) {
146
+ const chunk = buffer.subarray(offset, offset + CHUNK_SIZE);
147
+ const hdr = Buffer.allocUnsafe(4);
148
+ hdr.writeUInt32BE(chunk.length, 0);
149
+ s.write(hdr);
150
+ s.write(chunk);
151
+ offset += chunk.length;
152
+ }
153
+ s.write(Buffer.alloc(4));
154
+ }));
155
+ },
156
+
157
+ scanStream(stream) {
158
+ return enqueue((sock) => scanOnSocket(sock, (s, onErr) => {
159
+ stream.on('error', onErr);
160
+ stream.on('data', (chunk) => {
161
+ const hdr = Buffer.allocUnsafe(4);
162
+ hdr.writeUInt32BE(chunk.length, 0);
163
+ s.write(hdr);
164
+ s.write(chunk);
165
+ });
166
+ stream.on('end', () => s.write(Buffer.alloc(4)));
167
+ }));
168
+ },
169
+
170
+ destroy() {
171
+ for (const slot of slots) {
172
+ if (slot.socket) { slot.socket.destroy(); slot.socket = null; }
173
+ slot.busy = false;
174
+ }
175
+ while (queue.length > 0) {
176
+ const { reject } = queue.shift();
177
+ reject(new Error('Pool destroyed'));
178
+ }
179
+ },
180
+ };
181
+ }
182
+
183
+ module.exports = { createPool };
@@ -31,55 +31,69 @@ function parseClamdResponse(raw) {
31
31
  * @param {number} [options.timeout=15000] - Socket idle timeout in ms.
32
32
  * @returns {Promise<'Clean'|'Malicious'|'ScanError'>}
33
33
  */
34
- function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = {}) {
35
- return new Promise((resolve, reject) => {
36
- if (typeof filePath !== 'string') {
37
- return reject(new Error('filePath must be a string'));
38
- }
39
- if (!fs.existsSync(filePath)) {
40
- return reject(new Error(`File not found: ${filePath}`));
41
- }
42
-
43
- const connOpts = socketPath ? { path: socketPath } : { host, port };
44
- const conn = net.createConnection(connOpts);
45
- const chunks = [];
46
- let settled = false;
47
-
48
- function settle(fn, value) {
49
- if (settled) return;
50
- settled = true;
51
- conn.destroy();
52
- fn(value);
53
- }
54
-
55
- conn.setTimeout(timeout);
56
- conn.on('timeout', () =>
57
- settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
58
- );
59
- conn.on('error', (err) => settle(reject, err));
60
- conn.on('data', (chunk) => chunks.push(chunk));
61
- conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
62
-
63
- conn.on('connect', () => {
64
- conn.write(CLAMD_INSTREAM);
65
-
66
- const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
67
-
68
- fileStream.on('error', (err) => settle(reject, err));
69
-
70
- fileStream.on('data', (chunk) => {
71
- const header = Buffer.allocUnsafe(4);
72
- header.writeUInt32BE(chunk.length, 0);
73
- conn.write(header);
74
- conn.write(chunk);
75
- });
34
+ function scanViaClamd(filePath, options = {}) {
35
+ const { retries = 0, retryDelay = 1000, host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = options;
36
+
37
+ function attempt() {
38
+ return new Promise((resolve, reject) => {
39
+ if (typeof filePath !== 'string') {
40
+ return reject(new Error('filePath must be a string'));
41
+ }
42
+ if (!fs.existsSync(filePath)) {
43
+ return reject(new Error(`File not found: ${filePath}`));
44
+ }
45
+
46
+ const connOpts = socketPath ? { path: socketPath } : { host, port };
47
+ const conn = net.createConnection(connOpts);
48
+ const chunks = [];
49
+ let settled = false;
50
+
51
+ function settle(fn, value) {
52
+ if (settled) return;
53
+ settled = true;
54
+ conn.destroy();
55
+ fn(value);
56
+ }
57
+
58
+ conn.setTimeout(timeout);
59
+ conn.on('timeout', () =>
60
+ settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
61
+ );
62
+ conn.on('error', (err) => settle(reject, err));
63
+ conn.on('data', (chunk) => chunks.push(chunk));
64
+ conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
65
+
66
+ conn.on('connect', () => {
67
+ conn.write(CLAMD_INSTREAM);
76
68
 
77
- fileStream.on('end', () => {
78
- conn.write(Buffer.alloc(4)); // terminating zero-length chunk
79
- conn.end();
69
+ const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
70
+
71
+ fileStream.on('error', (err) => settle(reject, err));
72
+
73
+ fileStream.on('data', (chunk) => {
74
+ const header = Buffer.allocUnsafe(4);
75
+ header.writeUInt32BE(chunk.length, 0);
76
+ conn.write(header);
77
+ conn.write(chunk);
78
+ });
79
+
80
+ fileStream.on('end', () => {
81
+ conn.write(Buffer.alloc(4)); // terminating zero-length chunk
82
+ conn.end();
83
+ });
80
84
  });
81
85
  });
82
- });
86
+ }
87
+
88
+ function run(left) {
89
+ return attempt().catch(async (err) => {
90
+ if (left <= 0) throw err;
91
+ await new Promise(r => setTimeout(r, retryDelay));
92
+ return run(left - 1);
93
+ });
94
+ }
95
+
96
+ return run(retries);
83
97
  }
84
98
 
85
99
  module.exports = { scanViaClamd };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { scanStream } = require('./ClamAVScanner.js');
4
+ const { Readable } = require('stream');
5
+
6
+ /**
7
+ * Scan an S3 object by streaming it directly to clamd — no disk I/O.
8
+ *
9
+ * Requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3
10
+ *
11
+ * @param {{ bucket: string, key: string, region?: string, credentials?: object }} s3Params
12
+ * @param {object} [options] - Same options as scanStream (host, port, socket, timeout, retries, retryDelay)
13
+ * @returns {Promise<symbol>}
14
+ */
15
+ async function scanS3({ bucket, key, region, credentials } = {}, options = {}) {
16
+ let S3Client, GetObjectCommand;
17
+ try {
18
+ ({ S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'));
19
+ } catch {
20
+ throw new Error('Install AWS SDK: npm install @aws-sdk/client-s3');
21
+ }
22
+
23
+ const clientOpts = {};
24
+ if (region) clientOpts.region = region;
25
+ if (credentials) clientOpts.credentials = credentials;
26
+
27
+ const client = new S3Client(clientOpts);
28
+ const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
29
+
30
+ const body = response.Body;
31
+ const stream = body instanceof Readable
32
+ ? body
33
+ : (Readable.fromWeb ? Readable.fromWeb(body) : body);
34
+
35
+ return scanStream(stream, options);
36
+ }
37
+
38
+ module.exports = { scanS3 };
@@ -25,46 +25,60 @@ function parseClamdResponse(raw) {
25
25
  * @param {number} [options.timeout=15000]
26
26
  * @returns {Promise<symbol>}
27
27
  */
28
- function scanStreamViaClamd(stream, { host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = {}) {
29
- return new Promise((resolve, reject) => {
30
- const connOpts = socketPath ? { path: socketPath } : { host, port };
31
- const conn = net.createConnection(connOpts);
32
- const chunks = [];
33
- let settled = false;
28
+ function scanStreamViaClamd(stream, options = {}) {
29
+ const { retries = 0, retryDelay = 1000, host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = options;
34
30
 
35
- function settle(fn, value) {
36
- if (settled) return;
37
- settled = true;
38
- conn.destroy();
39
- fn(value);
40
- }
31
+ function attempt() {
32
+ return new Promise((resolve, reject) => {
33
+ const connOpts = socketPath ? { path: socketPath } : { host, port };
34
+ const conn = net.createConnection(connOpts);
35
+ const chunks = [];
36
+ let settled = false;
41
37
 
42
- conn.setTimeout(timeout);
43
- conn.on('timeout', () =>
44
- settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
45
- );
46
- conn.on('error', (err) => settle(reject, err));
47
- conn.on('data', (chunk) => chunks.push(chunk));
48
- conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
38
+ function settle(fn, value) {
39
+ if (settled) return;
40
+ settled = true;
41
+ conn.destroy();
42
+ fn(value);
43
+ }
49
44
 
50
- conn.on('connect', () => {
51
- conn.write(CLAMD_INSTREAM);
45
+ conn.setTimeout(timeout);
46
+ conn.on('timeout', () =>
47
+ settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
48
+ );
49
+ conn.on('error', (err) => settle(reject, err));
50
+ conn.on('data', (chunk) => chunks.push(chunk));
51
+ conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
52
52
 
53
- stream.on('error', (err) => settle(reject, err));
53
+ conn.on('connect', () => {
54
+ conn.write(CLAMD_INSTREAM);
54
55
 
55
- stream.on('data', (chunk) => {
56
- const header = Buffer.allocUnsafe(4);
57
- header.writeUInt32BE(chunk.length, 0);
58
- conn.write(header);
59
- conn.write(chunk);
60
- });
56
+ stream.on('error', (err) => settle(reject, err));
57
+
58
+ stream.on('data', (chunk) => {
59
+ const header = Buffer.allocUnsafe(4);
60
+ header.writeUInt32BE(chunk.length, 0);
61
+ conn.write(header);
62
+ conn.write(chunk);
63
+ });
61
64
 
62
- stream.on('end', () => {
63
- conn.write(Buffer.alloc(4)); // terminating zero-length chunk
64
- conn.end();
65
+ stream.on('end', () => {
66
+ conn.write(Buffer.alloc(4)); // terminating zero-length chunk
67
+ conn.end();
68
+ });
65
69
  });
66
70
  });
67
- });
71
+ }
72
+
73
+ function run(left) {
74
+ return attempt().catch(async (err) => {
75
+ if (left <= 0) throw err;
76
+ await new Promise(r => setTimeout(r, retryDelay));
77
+ return run(left - 1);
78
+ });
79
+ }
80
+
81
+ return run(retries);
68
82
  }
69
83
 
70
84
  module.exports = { scanStreamViaClamd };
package/src/Watcher.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { scan } = require('./ClamAVScanner.js');
6
+ const { Verdict } = require('./verdicts.js');
7
+
8
+ const DEBOUNCE_MS = 300;
9
+
10
+ /**
11
+ * Watch a directory for new/modified files and scan each one automatically.
12
+ * Uses fs.watch (no dependencies) with a 300 ms debounce.
13
+ *
14
+ * @param {string} dirPath
15
+ * @param {object} [options] - Passed to scan() (host, port, socket, timeout, retries, retryDelay)
16
+ * @param {{ onClean?: Function, onMalicious?: Function, onError?: Function }} [callbacks]
17
+ * @returns {import('fs').FSWatcher}
18
+ */
19
+ function watch(dirPath, options = {}, { onClean, onMalicious, onError } = {}) {
20
+ const timers = new Map();
21
+
22
+ return fs.watch(dirPath, { recursive: true }, (_eventType, filename) => {
23
+ if (!filename) return;
24
+
25
+ const fullPath = path.join(dirPath, filename);
26
+
27
+ if (timers.has(fullPath)) clearTimeout(timers.get(fullPath));
28
+
29
+ timers.set(fullPath, setTimeout(async () => {
30
+ timers.delete(fullPath);
31
+
32
+ if (!fs.existsSync(fullPath)) return;
33
+
34
+ let stat;
35
+ try { stat = fs.statSync(fullPath); } catch { return; }
36
+ if (!stat.isFile()) return;
37
+
38
+ try {
39
+ const verdict = await scan(fullPath, options);
40
+ if (verdict === Verdict.Clean) onClean && onClean(fullPath);
41
+ else if (verdict === Verdict.Malicious) onMalicious && onMalicious(fullPath);
42
+ else onError && onError(new Error(`ScanError for ${fullPath}`), fullPath);
43
+ } catch (err) {
44
+ onError && onError(err, fullPath);
45
+ }
46
+ }, DEBOUNCE_MS));
47
+ });
48
+ }
49
+
50
+ module.exports = { watch };
package/src/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  const { scan, scanBuffer, scanStream, scanDirectory } = require('./ClamAVScanner.js');
2
2
  const { Verdict } = require('./verdicts.js');
3
3
  const { middleware } = require('./middleware.js');
4
+ const { scanS3 } = require('./S3Scanner.js');
5
+ const { createPool } = require('./ClamdPool.js');
6
+ const { watch } = require('./Watcher.js');
4
7
 
5
- module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware };
8
+ module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch };
@@ -0,0 +1,154 @@
1
+ import { Readable } from 'stream';
2
+ import { FSWatcher } from 'fs';
3
+ import { IncomingMessage, ServerResponse } from 'http';
4
+
5
+ /** Options passed to any scan function */
6
+ export interface ScanOptions {
7
+ /** clamd hostname — enables TCP mode when set */
8
+ host?: string;
9
+ /** clamd port (default: 3310) */
10
+ port?: number;
11
+ /** Path to a clamd UNIX domain socket (e.g. /run/clamav/clamd.sock) */
12
+ socket?: string;
13
+ /** Socket idle timeout in milliseconds, clamd mode only (default: 15000) */
14
+ timeout?: number;
15
+ /** Number of retry attempts on connection error (default: 0) */
16
+ retries?: number;
17
+ /** Delay in milliseconds between retries (default: 1000) */
18
+ retryDelay?: number;
19
+ }
20
+
21
+ /** Options for the Express/Fastify middleware */
22
+ export interface MiddlewareOptions extends ScanOptions {
23
+ /** multer field name to look for uploaded files (default: 'file') */
24
+ uploadField?: string;
25
+ }
26
+
27
+ /** Result returned by scanDirectory */
28
+ export interface DirectoryScanResult {
29
+ /** Absolute paths of files that scanned clean */
30
+ clean: string[];
31
+ /** Absolute paths of infected files */
32
+ malicious: string[];
33
+ /** Absolute paths of files that produced a scan error */
34
+ errors: string[];
35
+ }
36
+
37
+ /** Opaque Symbol-based scan verdicts */
38
+ export declare const Verdict: {
39
+ readonly Clean: unique symbol;
40
+ readonly Malicious: unique symbol;
41
+ readonly ScanError: unique symbol;
42
+ };
43
+
44
+ /** The type of any Verdict symbol */
45
+ export type VerdictValue = typeof Verdict[keyof typeof Verdict];
46
+
47
+ type NextFunction = (err?: unknown) => void;
48
+ type RequestHandler = (
49
+ req: IncomingMessage,
50
+ res: ServerResponse,
51
+ next: NextFunction
52
+ ) => void | Promise<void>;
53
+
54
+ /**
55
+ * Scan a file at the given path.
56
+ * Resolves to Verdict.Clean, Verdict.Malicious, or Verdict.ScanError.
57
+ * Rejects if the file is not found or clamscan is unavailable.
58
+ */
59
+ export declare function scan(filePath: string, options?: ScanOptions): Promise<VerdictValue>;
60
+
61
+ /**
62
+ * Scan an in-memory Buffer.
63
+ * In TCP/socket mode the buffer is streamed to clamd with no disk I/O.
64
+ */
65
+ export declare function scanBuffer(buffer: Buffer, options?: ScanOptions): Promise<VerdictValue>;
66
+
67
+ /**
68
+ * Scan a Node.js Readable stream.
69
+ * In TCP/socket mode the stream is piped to clamd with no disk I/O.
70
+ */
71
+ export declare function scanStream(stream: Readable, options?: ScanOptions): Promise<VerdictValue>;
72
+
73
+ /**
74
+ * Recursively scan every file under dirPath.
75
+ * Per-file errors are caught and collected without aborting the full scan.
76
+ */
77
+ export declare function scanDirectory(
78
+ dirPath: string,
79
+ options?: ScanOptions
80
+ ): Promise<DirectoryScanResult>;
81
+
82
+ /**
83
+ * Express / Fastify middleware that scans multer-uploaded files
84
+ * (req.file / req.files) and responds HTTP 403 on any infection.
85
+ * Call after multer, before your route handler.
86
+ */
87
+ export declare function middleware(options?: MiddlewareOptions): RequestHandler;
88
+
89
+ /** Parameters for scanS3 */
90
+ export interface S3ScanParams {
91
+ /** S3 bucket name */
92
+ bucket: string;
93
+ /** S3 object key */
94
+ key: string;
95
+ /** AWS region */
96
+ region?: string;
97
+ /** AWS credentials object */
98
+ credentials?: object;
99
+ }
100
+
101
+ /**
102
+ * Scan an S3 object by streaming it directly via GetObjectCommand — no disk I/O.
103
+ * Requires @aws-sdk/client-s3 to be installed separately.
104
+ */
105
+ export declare function scanS3(params: S3ScanParams, options?: ScanOptions): Promise<VerdictValue>;
106
+
107
+ /** Options for createPool */
108
+ export interface PoolOptions {
109
+ host?: string;
110
+ port?: number;
111
+ socket?: string;
112
+ /** Number of persistent connections to maintain (default: 5) */
113
+ size?: number;
114
+ timeout?: number;
115
+ }
116
+
117
+ /** A pool of persistent clamd connections */
118
+ export interface ClamdPool {
119
+ /** Scan a file by path using a pooled connection */
120
+ scan(filePath: string): Promise<VerdictValue>;
121
+ /** Scan an in-memory Buffer using a pooled connection */
122
+ scanBuffer(buffer: Buffer): Promise<VerdictValue>;
123
+ /** Scan a Readable stream using a pooled connection */
124
+ scanStream(stream: Readable): Promise<VerdictValue>;
125
+ /** Destroy all pooled connections and reject any queued requests */
126
+ destroy(): void;
127
+ }
128
+
129
+ /**
130
+ * Create a pool of persistent clamd connections for high-throughput scanning.
131
+ * Queues requests when all connections are busy.
132
+ */
133
+ export declare function createPool(options?: PoolOptions): ClamdPool;
134
+
135
+ /** Callbacks for the watch() function */
136
+ export interface WatchCallbacks {
137
+ /** Called when a scanned file is clean */
138
+ onClean?: (filePath: string) => void;
139
+ /** Called when a scanned file is malicious */
140
+ onMalicious?: (filePath: string) => void;
141
+ /** Called on scan error or infrastructure failure */
142
+ onError?: (err: Error, filePath?: string) => void;
143
+ }
144
+
145
+ /**
146
+ * Watch a directory for new/modified files and scan each automatically.
147
+ * Uses fs.watch with a 300 ms debounce. No dependencies.
148
+ * Returns an FSWatcher; call .close() to stop watching.
149
+ */
150
+ export declare function watch(
151
+ dirPath: string,
152
+ options?: ScanOptions,
153
+ callbacks?: WatchCallbacks
154
+ ): FSWatcher;