Skip to content

Commit 5f616ad

Browse files
authored
Add +LINUX and +WINDOWS doctest options (#2507)
* Add `+LINUX` and `+WINDOWS` doctest options This allows to selectively run tests only on a single platform. We can add `# doctest: +LINUX` comments to tests that cannot work on Windows and the other way around. To easily skip a lot of tests the `doctest_additional_flags` global variable can be defined in a `testsetup`. This is achieved by monkey patching sphinx doctest's DocTestBuilder to use our own DocTestRunner which removes examples from the tests that have flags that don't match the platform we're running on. * Limit Sphinx version to secure platform patches Avoid major versions which might change the API. We have to check if the platform optionflags still work on newer versions once they are available. * CI: Run doctests with coverage on Windows Disable all non-trivial tests on Windows for now. The goal is to reduce the amount of linux-only tests. * Only apply platform patch on Python 3 * Disable uploading coverage on Windows The handrolled coveralls upload cannot handle mixed operating systems. Refs #2480 * Use threading.Timer for doctest timeout To interrupt the code running on the main thread, we send a signal using `_thread.interrupt_main()`. By default this causes a KeyboardInterrupt exception, which might be handled explicitly. To raise an explicit EndlessLoop exception inside the code that is taking too long, register a SIGABRT signal handler which raises the EndlessLoop exception. The exception from the signal handler is added to the call stack and handled by the code currently running. This allows to print a better stack trace on timeout. It is the same concept as the old implementation using `signal.alarm` but platform agnostic. https://anonbadger.wordpress.com/2018/12/15/python-signal-handlers-and-exceptions/ * Add POSIX optionflag Run test on other UNIX systems too if they don't use Linux specifics. Add a TODO optionflag too to mark platform restrictions that might be too strict and should be looked at. * Enable tube and tube/sockets tests on Windows * Use `signal.alarm` for timeouts if it's available * Update CHANGELOG
1 parent 29fb02f commit 5f616ad

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+265
-55
lines changed

.github/workflows/ci.yml

+15
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,25 @@ jobs:
284284
pip install --upgrade pip
285285
pip install --upgrade --editable .
286286
287+
- name: Install documentation dependencies
288+
run: pip install -r docs/requirements.txt
289+
287290
- name: Sanity checks
288291
run: |
289292
python -bb -c 'from pwn import *'
290293
python -bb examples/text.py
294+
295+
- name: Coverage doctests
296+
run: |
297+
python -bb -m coverage run -m sphinx -b doctest docs/source docs/build/doctest
298+
299+
# FIXME: Paths are broken when uploading coverage on ubuntu
300+
# coverage.exceptions.NoSource: No source for code: '/home/runner/work/pwntools/pwntools/D:\a\pwntools\pwntools\pwn\__init__.py'.
301+
# - uses: actions/upload-artifact@v4
302+
# with:
303+
# name: coverage-windows
304+
# path: .coverage*
305+
# include-hidden-files: true
291306

292307
upload-coverage:
293308
runs-on: ubuntu-latest

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ The table below shows which release corresponds to each branch, and what date th
7474

7575
## 5.0.0 (`dev`)
7676

77+
- [#2507][2507] Add `+LINUX` and `+WINDOWS` doctest options and start proper testing on Windows
78+
79+
[2507]: https://github.com/Gallopsled/pwntools/pull/2507
7780

7881
## 4.15.0 (`beta`)
7982
- [#2508][2508] Ignore a warning when compiling with asm on nix

docs/requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ psutil
1919
requests>=2.5.1
2020
ropgadget>=5.3
2121
sphinx==1.8.6; python_version<'3'
22-
sphinx>=7.0.0; python_version>='3'
22+
sphinx>=8.1.3, <9; python_version>='3'
2323
sphinx_rtd_theme
2424
sphinxcontrib-autoprogram<=0.1.5

docs/source/adb.rst

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from pwn import *
55
adb = pwnlib.adb
66

7+
import doctest
8+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
9+
710
:mod:`pwnlib.adb` --- Android Debug Bridge
811
=====================================================
912

docs/source/asm.rst

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
import subprocess
55
from pwn import *
66

7+
# TODO: Remove global POSIX flag
8+
import doctest
9+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
10+
711
:mod:`pwnlib.asm` --- Assembler functions
812
=========================================
913

docs/source/conf.py

+76-8
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,15 @@ def dont_skip_any_doctests(app, what, name, obj, skip, options):
398398

399399
class _DummyClass(object): pass
400400

401+
# doctest optionflags for platform-specific tests
402+
# they are skipped on other platforms
403+
WINDOWS = doctest.register_optionflag('WINDOWS')
404+
LINUX = doctest.register_optionflag('LINUX')
405+
POSIX = doctest.register_optionflag('POSIX')
406+
407+
# doctest optionflag for tests that haven't been looked at yet
408+
TODO = doctest.register_optionflag('TODO')
409+
401410
class Py2OutputChecker(_DummyClass, doctest.OutputChecker):
402411
def check_output(self, want, got, optionflags):
403412
sup = super(Py2OutputChecker, self).check_output
@@ -425,27 +434,86 @@ def check_output(self, want, got, optionflags):
425434
return False
426435
return True
427436

437+
import sphinx.ext.doctest
438+
439+
class PlatformDocTestRunner(sphinx.ext.doctest.SphinxDocTestRunner):
440+
def run(self, test, compileflags=None, out=None, clear_globs=True):
441+
original_optionflags = self.optionflags | test.globs.get('doctest_additional_flags', 0)
442+
def filter_platform(example):
443+
optionflags = original_optionflags
444+
if example.options:
445+
for (optionflag, val) in example.options.items():
446+
if val:
447+
optionflags |= optionflag
448+
else:
449+
optionflags &= ~optionflag
450+
451+
if (optionflags & WINDOWS) == WINDOWS and sys.platform != 'win32':
452+
return False
453+
if (optionflags & LINUX) == LINUX and sys.platform != 'linux':
454+
return False
455+
if (optionflags & POSIX) == POSIX and os.name != 'posix':
456+
return False
457+
return True
458+
459+
test.examples[:] = [example for example in test.examples if filter_platform(example)]
460+
461+
return super(PlatformDocTestRunner, self).run(test, compileflags, out, clear_globs)
462+
463+
class PlatformDocTestBuilder(sphinx.ext.doctest.DocTestBuilder):
464+
_test_runner = None
465+
466+
@property
467+
def test_runner(self):
468+
return self._test_runner
469+
470+
@test_runner.setter
471+
def test_runner(self, value):
472+
self._test_runner = PlatformDocTestRunner(value._checker, value._verbose, value.optionflags)
473+
428474
def py2_doctest_init(self, checker=None, verbose=None, optionflags=0):
429475
if checker is None:
430476
checker = Py2OutputChecker()
431477
doctest.DocTestRunner.__init__(self, checker, verbose, optionflags)
432478

433479
if 'doctest' in sys.argv:
434-
def setup(app):
435-
pass # app.connect('autodoc-skip-member', dont_skip_any_doctests)
436480

437481
if sys.version_info[:1] < (3,):
438-
import sphinx.ext.doctest
439482
sphinx.ext.doctest.SphinxDocTestRunner.__init__ = py2_doctest_init
440483
else:
484+
def setup(app):
485+
app.add_builder(PlatformDocTestBuilder, override=True)
486+
# app.connect('autodoc-skip-member', dont_skip_any_doctests)
441487
# monkey patching paramiko due to https://github.com/paramiko/paramiko/pull/1661
442488
import paramiko.client
443489
import binascii
444490
paramiko.client.hexlify = lambda x: binascii.hexlify(x).decode()
445491
paramiko.util.safe_string = lambda x: '' # function result never *actually used*
446492
class EndlessLoop(Exception): pass
447-
def alrm_handler(sig, frame):
448-
signal.alarm(180) # three minutes
449-
raise EndlessLoop()
450-
signal.signal(signal.SIGALRM, alrm_handler)
451-
signal.alarm(600) # ten minutes
493+
if hasattr(signal, 'alarm'):
494+
def alrm_handler(sig, frame):
495+
signal.alarm(180) # three minutes
496+
raise EndlessLoop()
497+
signal.signal(signal.SIGALRM, alrm_handler)
498+
signal.alarm(600) # ten minutes
499+
else:
500+
def sigabrt_handler(signum, frame):
501+
raise EndlessLoop()
502+
# thread.interrupt_main received the signum parameter in Python 3.10
503+
if sys.version_info >= (3, 10):
504+
signal.signal(signal.SIGABRT, sigabrt_handler)
505+
def alrm_handler():
506+
try:
507+
import thread
508+
except ImportError:
509+
import _thread as thread
510+
# pre Python 3.10 this raises a KeyboardInterrupt in the main thread.
511+
# it might not show a traceback in that case, but it will stop the endless loop.
512+
thread.interrupt_main(signal.SIGABRT)
513+
timer = threading.Timer(interval=180, function=alrm_handler) # three minutes
514+
timer.daemon = True
515+
timer.start()
516+
import threading
517+
timer = threading.Timer(interval=600, function=alrm_handler) # ten minutes
518+
timer.daemon = True
519+
timer.start()

docs/source/elf/corefile.rst

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
# Set the environment here so it's not in the middle of our tests.
1919
os.environ.setdefault('SHELL', '/bin/sh')
2020

21+
import doctest
22+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
23+
2124

2225
:mod:`pwnlib.elf.corefile` --- Core Files
2326
===========================================================

docs/source/elf/elf.rst

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
from pwnlib.elf.maps import CAT_PROC_MAPS_EXIT
66
import shutil
77

8+
# TODO: Remove global POSIX flag
9+
import doctest
10+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
11+
812
:mod:`pwnlib.elf.elf` --- ELF Files
913
===========================================================
1014

docs/source/encoders.rst

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
.. testsetup:: *
22

33
from pwn import *
4+
5+
# TODO: Remove global POSIX flag
6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
48

59
:mod:`pwnlib.encoders` --- Encoding Shellcode
610
===============================================

docs/source/filesystem.rst

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
from pwnlib.tubes.ssh import ssh
77
from pwnlib.filesystem import *
88

9+
# TODO: Remove global POSIX flag
10+
import doctest
11+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
12+
913
:mod:`pwnlib.filesystem` --- Manipulating Files Locally and Over SSH
1014
====================================================================
1115

docs/source/gdb.rst

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
context.arch = 'amd64'
55
context.terminal = [os.path.join(os.path.dirname(pwnlib.__file__), 'gdb_faketerminal.py')]
66

7+
# TODO: Test on cygwin too
8+
import doctest
9+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
10+
711
:mod:`pwnlib.gdb` --- Working with GDB
812
======================================
913

docs/source/intro.rst

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from pwn import *
44

5+
import doctest
6+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
7+
58
Getting Started
69
========================
710

docs/source/libcdb.rst

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from pwn import *
44
from pwnlib.libcdb import *
55

6+
# TODO: Remove global POSIX flag
7+
import doctest
8+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
9+
610
:mod:`pwnlib.libcdb` --- Libc Database
711
===========================================
812

docs/source/qemu.rst

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from pwn import *
44

5+
# TODO: Remove global POSIX flag
6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
8+
59

610
:mod:`pwnlib.qemu` --- QEMU Utilities
711
==========================================

docs/source/rop/rop.rst

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020
context.clear()
2121

22+
# TODO: Remove global LINUX flag
23+
import doctest
24+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
25+
2226

2327
:mod:`pwnlib.rop.rop` --- Return Oriented Programming
2428
==========================================================

docs/source/rop/srop.rst

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from pwnlib.elf import ELF
88
from pwnlib.tubes.process import process
99

10+
import doctest
11+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
12+
1013
:mod:`pwnlib.rop.srop` --- Sigreturn Oriented Programming
1114
==========================================================
1215

docs/source/runner.rst

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from pwnlib.runner import *
44
from pwnlib.asm import asm
55

6+
# TODO: Remove global POSIX flag
7+
import doctest
8+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
9+
610
:mod:`pwnlib.runner` --- Running Shellcode
711
===========================================
812

docs/source/shellcraft.rst

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from pwnlib import shellcraft
44

5+
# TODO: Remove global POSIX flag
6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
8+
59
:mod:`pwnlib.shellcraft` --- Shellcode generation
610
=================================================
711

docs/source/shellcraft/aarch64.rst

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from pwn import *
44
context.clear(arch='aarch64')
55

6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
8+
69
:mod:`pwnlib.shellcraft.aarch64` --- Shellcode for AArch64
710
===========================================================
811

docs/source/shellcraft/amd64.rst

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from pwn import *
44
context.clear(arch='amd64')
55

6+
# TODO: POSIX/WINDOWS shellcode test
7+
import doctest
8+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
9+
610
:mod:`pwnlib.shellcraft.amd64` --- Shellcode for AMD64
711
===========================================================
812

docs/source/shellcraft/arm.rst

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from pwn import *
44
context.clear(arch='arm')
55

6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
8+
69
:mod:`pwnlib.shellcraft.arm` --- Shellcode for ARM
710
===========================================================
811

docs/source/shellcraft/i386.rst

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from pwn import *
44
context.clear(arch='i386')
55

6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
8+
69
:mod:`pwnlib.shellcraft.i386` --- Shellcode for Intel 80386
710
===========================================================
811

docs/source/shellcraft/mips.rst

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
context.clear(arch='mips')
1414

15+
import doctest
16+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
17+
1518
:mod:`pwnlib.shellcraft.mips` --- Shellcode for MIPS
1619
===========================================================
1720

docs/source/shellcraft/riscv64.rst

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from pwn import *
44
context.clear(arch='riscv64')
55

6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
8+
69
:mod:`pwnlib.shellcraft.riscv64` --- Shellcode for RISCV64
710
==========================================================
811

docs/source/shellcraft/thumb.rst

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from pwn import *
44
context.clear(arch='thumb')
55

6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['LINUX']
8+
69
:mod:`pwnlib.shellcraft.thumb` --- Shellcode for Thumb Mode
710
===========================================================
811

docs/source/tubes/processes.rst

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from pwn import *
44

5+
# TODO: Remove global POSIX flag
6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
8+
59
:mod:`pwnlib.tubes.process` --- Processes
610
===========================================================
711

docs/source/tubes/serial.rst

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from pwn import *
44

5+
# TODO: Remove global POSIX flag
6+
import doctest
7+
doctest_additional_flags = doctest.OPTIONFLAGS_BY_NAME['POSIX']
8+
59
:mod:`pwnlib.tubes.serialtube` --- Serial Ports
610
===========================================================
711

0 commit comments

Comments
 (0)