aiwaf-rust 0.1.0__tar.gz

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.
@@ -0,0 +1,94 @@
1
+ name: Build and Publish Rust Package
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build-wheels:
13
+ name: Build wheels (${{ matrix.os }} / py${{ matrix.python }})
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, macos-latest, windows-latest]
19
+ python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - id: setup-python
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python }}
28
+
29
+ - name: Build wheel
30
+ uses: PyO3/maturin-action@v1
31
+ env:
32
+ PYTHON_SYS_EXECUTABLE: ${{ steps.setup-python.outputs.python-path }}
33
+ PYO3_PYTHON: ${{ steps.setup-python.outputs.python-path }}
34
+ with:
35
+ command: build
36
+ args: --release --out dist
37
+ sccache: true
38
+
39
+ - name: Upload wheels
40
+ uses: actions/upload-artifact@v4
41
+ with:
42
+ name: rust-wheels-${{ matrix.os }}-py${{ matrix.python }}
43
+ path: dist
44
+
45
+ build-sdist:
46
+ name: Build sdist
47
+ runs-on: ubuntu-latest
48
+
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+
52
+ - uses: actions/setup-python@v5
53
+ with:
54
+ python-version: "3.12"
55
+
56
+ - name: Build sdist
57
+ uses: PyO3/maturin-action@v1
58
+ with:
59
+ command: sdist
60
+ args: --out dist
61
+
62
+ - name: Upload sdist
63
+ uses: actions/upload-artifact@v4
64
+ with:
65
+ name: rust-sdist
66
+ path: dist
67
+
68
+ pypi-publish:
69
+ name: Publish to PyPI
70
+ runs-on: ubuntu-latest
71
+ needs: [build-wheels, build-sdist]
72
+ permissions:
73
+ id-token: write
74
+ environment:
75
+ name: pypi
76
+
77
+ steps:
78
+ - name: Download wheels
79
+ uses: actions/download-artifact@v4
80
+ with:
81
+ pattern: rust-wheels-*
82
+ path: dist
83
+ merge-multiple: true
84
+
85
+ - name: Download sdist
86
+ uses: actions/download-artifact@v4
87
+ with:
88
+ name: rust-sdist
89
+ path: dist
90
+
91
+ - name: Publish
92
+ uses: pypa/gh-action-pypi-publish@release/v1
93
+ with:
94
+ packages-dir: dist/
@@ -0,0 +1,5 @@
1
+ # Python virtual environments
2
+ .venv/
3
+ venv/
4
+ env/
5
+ ENV/
@@ -0,0 +1,291 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "aho-corasick"
7
+ version = "1.1.4"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10
+ dependencies = [
11
+ "memchr",
12
+ ]
13
+
14
+ [[package]]
15
+ name = "aiwaf_rust"
16
+ version = "0.1.0"
17
+ dependencies = [
18
+ "once_cell",
19
+ "pyo3",
20
+ "regex",
21
+ ]
22
+
23
+ [[package]]
24
+ name = "autocfg"
25
+ version = "1.5.0"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
28
+
29
+ [[package]]
30
+ name = "bitflags"
31
+ version = "2.11.0"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
34
+
35
+ [[package]]
36
+ name = "cfg-if"
37
+ version = "1.0.4"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
40
+
41
+ [[package]]
42
+ name = "heck"
43
+ version = "0.4.1"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
46
+
47
+ [[package]]
48
+ name = "indoc"
49
+ version = "2.0.7"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
52
+ dependencies = [
53
+ "rustversion",
54
+ ]
55
+
56
+ [[package]]
57
+ name = "libc"
58
+ version = "0.2.182"
59
+ source = "registry+https://github.com/rust-lang/crates.io-index"
60
+ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
61
+
62
+ [[package]]
63
+ name = "lock_api"
64
+ version = "0.4.14"
65
+ source = "registry+https://github.com/rust-lang/crates.io-index"
66
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
67
+ dependencies = [
68
+ "scopeguard",
69
+ ]
70
+
71
+ [[package]]
72
+ name = "memchr"
73
+ version = "2.8.0"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
+
77
+ [[package]]
78
+ name = "memoffset"
79
+ version = "0.9.1"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
82
+ dependencies = [
83
+ "autocfg",
84
+ ]
85
+
86
+ [[package]]
87
+ name = "once_cell"
88
+ version = "1.21.3"
89
+ source = "registry+https://github.com/rust-lang/crates.io-index"
90
+ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
91
+
92
+ [[package]]
93
+ name = "parking_lot"
94
+ version = "0.12.5"
95
+ source = "registry+https://github.com/rust-lang/crates.io-index"
96
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
97
+ dependencies = [
98
+ "lock_api",
99
+ "parking_lot_core",
100
+ ]
101
+
102
+ [[package]]
103
+ name = "parking_lot_core"
104
+ version = "0.9.12"
105
+ source = "registry+https://github.com/rust-lang/crates.io-index"
106
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
107
+ dependencies = [
108
+ "cfg-if",
109
+ "libc",
110
+ "redox_syscall",
111
+ "smallvec",
112
+ "windows-link",
113
+ ]
114
+
115
+ [[package]]
116
+ name = "portable-atomic"
117
+ version = "1.13.1"
118
+ source = "registry+https://github.com/rust-lang/crates.io-index"
119
+ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
120
+
121
+ [[package]]
122
+ name = "proc-macro2"
123
+ version = "1.0.106"
124
+ source = "registry+https://github.com/rust-lang/crates.io-index"
125
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
126
+ dependencies = [
127
+ "unicode-ident",
128
+ ]
129
+
130
+ [[package]]
131
+ name = "pyo3"
132
+ version = "0.21.2"
133
+ source = "registry+https://github.com/rust-lang/crates.io-index"
134
+ checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8"
135
+ dependencies = [
136
+ "cfg-if",
137
+ "indoc",
138
+ "libc",
139
+ "memoffset",
140
+ "parking_lot",
141
+ "portable-atomic",
142
+ "pyo3-build-config",
143
+ "pyo3-ffi",
144
+ "pyo3-macros",
145
+ "unindent",
146
+ ]
147
+
148
+ [[package]]
149
+ name = "pyo3-build-config"
150
+ version = "0.21.2"
151
+ source = "registry+https://github.com/rust-lang/crates.io-index"
152
+ checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50"
153
+ dependencies = [
154
+ "once_cell",
155
+ "target-lexicon",
156
+ ]
157
+
158
+ [[package]]
159
+ name = "pyo3-ffi"
160
+ version = "0.21.2"
161
+ source = "registry+https://github.com/rust-lang/crates.io-index"
162
+ checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403"
163
+ dependencies = [
164
+ "libc",
165
+ "pyo3-build-config",
166
+ ]
167
+
168
+ [[package]]
169
+ name = "pyo3-macros"
170
+ version = "0.21.2"
171
+ source = "registry+https://github.com/rust-lang/crates.io-index"
172
+ checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c"
173
+ dependencies = [
174
+ "proc-macro2",
175
+ "pyo3-macros-backend",
176
+ "quote",
177
+ "syn",
178
+ ]
179
+
180
+ [[package]]
181
+ name = "pyo3-macros-backend"
182
+ version = "0.21.2"
183
+ source = "registry+https://github.com/rust-lang/crates.io-index"
184
+ checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c"
185
+ dependencies = [
186
+ "heck",
187
+ "proc-macro2",
188
+ "pyo3-build-config",
189
+ "quote",
190
+ "syn",
191
+ ]
192
+
193
+ [[package]]
194
+ name = "quote"
195
+ version = "1.0.44"
196
+ source = "registry+https://github.com/rust-lang/crates.io-index"
197
+ checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
198
+ dependencies = [
199
+ "proc-macro2",
200
+ ]
201
+
202
+ [[package]]
203
+ name = "redox_syscall"
204
+ version = "0.5.18"
205
+ source = "registry+https://github.com/rust-lang/crates.io-index"
206
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
207
+ dependencies = [
208
+ "bitflags",
209
+ ]
210
+
211
+ [[package]]
212
+ name = "regex"
213
+ version = "1.12.3"
214
+ source = "registry+https://github.com/rust-lang/crates.io-index"
215
+ checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
216
+ dependencies = [
217
+ "aho-corasick",
218
+ "memchr",
219
+ "regex-automata",
220
+ "regex-syntax",
221
+ ]
222
+
223
+ [[package]]
224
+ name = "regex-automata"
225
+ version = "0.4.14"
226
+ source = "registry+https://github.com/rust-lang/crates.io-index"
227
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
228
+ dependencies = [
229
+ "aho-corasick",
230
+ "memchr",
231
+ "regex-syntax",
232
+ ]
233
+
234
+ [[package]]
235
+ name = "regex-syntax"
236
+ version = "0.8.9"
237
+ source = "registry+https://github.com/rust-lang/crates.io-index"
238
+ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
239
+
240
+ [[package]]
241
+ name = "rustversion"
242
+ version = "1.0.22"
243
+ source = "registry+https://github.com/rust-lang/crates.io-index"
244
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
245
+
246
+ [[package]]
247
+ name = "scopeguard"
248
+ version = "1.2.0"
249
+ source = "registry+https://github.com/rust-lang/crates.io-index"
250
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
251
+
252
+ [[package]]
253
+ name = "smallvec"
254
+ version = "1.15.1"
255
+ source = "registry+https://github.com/rust-lang/crates.io-index"
256
+ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
257
+
258
+ [[package]]
259
+ name = "syn"
260
+ version = "2.0.116"
261
+ source = "registry+https://github.com/rust-lang/crates.io-index"
262
+ checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
263
+ dependencies = [
264
+ "proc-macro2",
265
+ "quote",
266
+ "unicode-ident",
267
+ ]
268
+
269
+ [[package]]
270
+ name = "target-lexicon"
271
+ version = "0.12.16"
272
+ source = "registry+https://github.com/rust-lang/crates.io-index"
273
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
274
+
275
+ [[package]]
276
+ name = "unicode-ident"
277
+ version = "1.0.23"
278
+ source = "registry+https://github.com/rust-lang/crates.io-index"
279
+ checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
280
+
281
+ [[package]]
282
+ name = "unindent"
283
+ version = "0.2.4"
284
+ source = "registry+https://github.com/rust-lang/crates.io-index"
285
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
286
+
287
+ [[package]]
288
+ name = "windows-link"
289
+ version = "0.2.1"
290
+ source = "registry+https://github.com/rust-lang/crates.io-index"
291
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "aiwaf_rust"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Rust-powered WAF heuristics exposed to Python via PyO3"
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ repository = "https://github.com/your-org/aiwaf-rust"
9
+ keywords = ["waf", "security", "python", "pyo3"]
10
+ categories = ["network-programming", "web-programming"]
11
+
12
+ [lib]
13
+ name = "aiwaf_rust"
14
+ crate-type = ["cdylib"]
15
+
16
+ [dependencies]
17
+ once_cell = "1"
18
+ pyo3 = { version = "0.21", features = ["extension-module"] }
19
+ regex = "1"
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AIWAF
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiwaf-rust
3
+ Version: 0.1.0
4
+ Classifier: Programming Language :: Python :: 3
5
+ Classifier: Programming Language :: Python :: 3 :: Only
6
+ Classifier: Programming Language :: Rust
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ License-File: LICENSE
10
+ Summary: Rust-powered WAF heuristics exposed to Python via PyO3
11
+ Author: AIWAF maintainers
12
+ License: MIT
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
15
+
16
+ # aiwaf-rust
17
+
18
+ Rust-based request-header and behavior heuristics exposed as a Python extension via PyO3.
19
+
20
+ ## Build locally
21
+
22
+ ```bash
23
+ pip install maturin
24
+ maturin develop
25
+ ```
26
+
27
+ ## Run Rust tests
28
+
29
+ ```bash
30
+ cargo test
31
+ ```
32
+
@@ -0,0 +1,16 @@
1
+ # aiwaf-rust
2
+
3
+ Rust-based request-header and behavior heuristics exposed as a Python extension via PyO3.
4
+
5
+ ## Build locally
6
+
7
+ ```bash
8
+ pip install maturin
9
+ maturin develop
10
+ ```
11
+
12
+ ## Run Rust tests
13
+
14
+ ```bash
15
+ cargo test
16
+ ```
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.6,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "aiwaf-rust"
7
+ version = "0.1.0"
8
+ description = "Rust-powered WAF heuristics exposed to Python via PyO3"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "AIWAF maintainers" }
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Rust",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent"
21
+ ]
22
+
23
+ [tool.maturin]
24
+ manifest-path = "Cargo.toml"
25
+ module-name = "aiwaf_rust"
@@ -0,0 +1,645 @@
1
+ use once_cell::sync::Lazy;
2
+ use pyo3::exceptions::PyKeyError;
3
+ use pyo3::prelude::*;
4
+ use pyo3::types::{PyAny, PyDict};
5
+ use pyo3::FromPyObject;
6
+ use regex::Regex;
7
+ use std::collections::{HashMap, HashSet};
8
+
9
+ static LEGITIMATE_BOTS: Lazy<Vec<Regex>> = Lazy::new(|| {
10
+ vec![
11
+ Regex::new(r"googlebot").unwrap(),
12
+ Regex::new(r"bingbot").unwrap(),
13
+ Regex::new(r"slurp").unwrap(),
14
+ Regex::new(r"duckduckbot").unwrap(),
15
+ Regex::new(r"baiduspider").unwrap(),
16
+ Regex::new(r"yandexbot").unwrap(),
17
+ Regex::new(r"facebookexternalhit").unwrap(),
18
+ Regex::new(r"twitterbot").unwrap(),
19
+ Regex::new(r"linkedinbot").unwrap(),
20
+ Regex::new(r"whatsapp").unwrap(),
21
+ Regex::new(r"telegrambot").unwrap(),
22
+ Regex::new(r"applebot").unwrap(),
23
+ Regex::new(r"pingdom").unwrap(),
24
+ Regex::new(r"uptimerobot").unwrap(),
25
+ Regex::new(r"statuscake").unwrap(),
26
+ Regex::new(r"site24x7").unwrap(),
27
+ ]
28
+ });
29
+
30
+ static SUSPICIOUS_UA: Lazy<Vec<(&'static str, Regex)>> = Lazy::new(|| {
31
+ vec![
32
+ (r"bot", Regex::new(r"bot").unwrap()),
33
+ (r"crawler", Regex::new(r"crawler").unwrap()),
34
+ (r"spider", Regex::new(r"spider").unwrap()),
35
+ (r"scraper", Regex::new(r"scraper").unwrap()),
36
+ (r"curl", Regex::new(r"curl").unwrap()),
37
+ (r"wget", Regex::new(r"wget").unwrap()),
38
+ (r"python", Regex::new(r"python").unwrap()),
39
+ (r"java", Regex::new(r"java").unwrap()),
40
+ (r"node", Regex::new(r"node").unwrap()),
41
+ (r"go-http", Regex::new(r"go-http").unwrap()),
42
+ (r"axios", Regex::new(r"axios").unwrap()),
43
+ (r"okhttp", Regex::new(r"okhttp").unwrap()),
44
+ (r"libwww", Regex::new(r"libwww").unwrap()),
45
+ (r"lwp-trivial", Regex::new(r"lwp-trivial").unwrap()),
46
+ (r"mechanize", Regex::new(r"mechanize").unwrap()),
47
+ (r"requests", Regex::new(r"requests").unwrap()),
48
+ (r"urllib", Regex::new(r"urllib").unwrap()),
49
+ (r"httpie", Regex::new(r"httpie").unwrap()),
50
+ (r"postman", Regex::new(r"postman").unwrap()),
51
+ (r"insomnia", Regex::new(r"insomnia").unwrap()),
52
+ (r"^$", Regex::new(r"^$").unwrap()),
53
+ (r"mozilla/4\.0$", Regex::new(r"mozilla/4\.0$").unwrap()),
54
+ ]
55
+ });
56
+
57
+ fn get_header(headers: &Bound<'_, PyDict>, key: &str) -> Option<String> {
58
+ headers
59
+ .get_item(key)
60
+ .ok()
61
+ .flatten()
62
+ .and_then(|v| v.str().ok())
63
+ .and_then(|s| s.to_str().ok().map(|v| v.to_string()))
64
+ }
65
+
66
+ fn has_header(headers: &Bound<'_, PyDict>, key: &str) -> bool {
67
+ match get_header(headers, key) {
68
+ Some(value) => !value.is_empty(),
69
+ None => false,
70
+ }
71
+ }
72
+
73
+ fn check_user_agent(user_agent: &str) -> Option<String> {
74
+ if user_agent.is_empty() {
75
+ return Some("Empty user agent".to_string());
76
+ }
77
+
78
+ let ua_lower = user_agent.to_lowercase();
79
+
80
+ for legit in LEGITIMATE_BOTS.iter() {
81
+ if legit.is_match(&ua_lower) {
82
+ return None;
83
+ }
84
+ }
85
+
86
+ for (pattern, regex) in SUSPICIOUS_UA.iter() {
87
+ if regex.is_match(&ua_lower) {
88
+ return Some(format!("Pattern: {}", pattern));
89
+ }
90
+ }
91
+
92
+ if user_agent.len() < 10 {
93
+ return Some("Too short".to_string());
94
+ }
95
+ if user_agent.len() > 500 {
96
+ return Some("Too long".to_string());
97
+ }
98
+
99
+ None
100
+ }
101
+
102
+ #[pyfunction]
103
+ fn validate_headers(headers: Bound<'_, PyDict>) -> PyResult<Option<String>> {
104
+ validate_headers_with_config(headers, None, None)
105
+ }
106
+
107
+ #[pyfunction]
108
+ fn validate_headers_with_config(
109
+ headers: Bound<'_, PyDict>,
110
+ required_headers: Option<Vec<String>>,
111
+ min_score: Option<i32>,
112
+ ) -> PyResult<Option<String>> {
113
+ let required = required_headers.unwrap_or_else(|| {
114
+ vec!["HTTP_USER_AGENT".to_string(), "HTTP_ACCEPT".to_string()]
115
+ });
116
+ let required_set: HashSet<String> = required.iter().cloned().collect();
117
+ let check_required = !required.is_empty();
118
+
119
+ let mut missing = Vec::new();
120
+ if check_required {
121
+ if required_set.contains("HTTP_USER_AGENT") && !has_header(&headers, "HTTP_USER_AGENT") {
122
+ missing.push("user-agent".to_string());
123
+ }
124
+ if required_set.contains("HTTP_ACCEPT") && !has_header(&headers, "HTTP_ACCEPT") {
125
+ missing.push("accept".to_string());
126
+ }
127
+ }
128
+
129
+ if !missing.is_empty() {
130
+ return Ok(Some(format!(
131
+ "Missing required headers: {}",
132
+ missing.join(", ")
133
+ )));
134
+ }
135
+
136
+ let user_agent = get_header(&headers, "HTTP_USER_AGENT").unwrap_or_default();
137
+ if let Some(reason) = check_user_agent(&user_agent) {
138
+ return Ok(Some(format!("Suspicious user agent: {}", reason)));
139
+ }
140
+
141
+ let server_protocol = get_header(&headers, "SERVER_PROTOCOL").unwrap_or_default();
142
+ let accept = get_header(&headers, "HTTP_ACCEPT").unwrap_or_default();
143
+ let accept_language = get_header(&headers, "HTTP_ACCEPT_LANGUAGE").unwrap_or_default();
144
+ let accept_encoding = get_header(&headers, "HTTP_ACCEPT_ENCODING").unwrap_or_default();
145
+ let connection = get_header(&headers, "HTTP_CONNECTION").unwrap_or_default();
146
+
147
+ if check_required {
148
+ if server_protocol.starts_with("HTTP/2")
149
+ && user_agent.to_lowercase().contains("mozilla/4.0")
150
+ {
151
+ return Ok(Some(
152
+ "Suspicious headers: HTTP/2 with old browser user agent".to_string(),
153
+ ));
154
+ }
155
+ if !user_agent.is_empty()
156
+ && accept.is_empty()
157
+ && required_set.contains("HTTP_ACCEPT")
158
+ {
159
+ return Ok(Some(
160
+ "Suspicious headers: User-Agent present but no Accept header".to_string(),
161
+ ));
162
+ }
163
+ if accept == "*/*" && accept_language.is_empty() && accept_encoding.is_empty() {
164
+ return Ok(Some(
165
+ "Suspicious headers: Generic Accept header without language/encoding".to_string(),
166
+ ));
167
+ }
168
+ if !user_agent.is_empty()
169
+ && accept_language.is_empty()
170
+ && accept_encoding.is_empty()
171
+ && connection.is_empty()
172
+ {
173
+ return Ok(Some(
174
+ "Suspicious headers: Missing all browser-standard headers".to_string(),
175
+ ));
176
+ }
177
+ if !user_agent.is_empty()
178
+ && server_protocol == "HTTP/1.0"
179
+ && user_agent.to_lowercase().contains("chrome")
180
+ {
181
+ return Ok(Some(
182
+ "Suspicious headers: Modern browser with HTTP/1.0".to_string(),
183
+ ));
184
+ }
185
+ }
186
+
187
+ let mut score = 0;
188
+ if has_header(&headers, "HTTP_USER_AGENT") {
189
+ score += 2;
190
+ }
191
+ if has_header(&headers, "HTTP_ACCEPT") {
192
+ score += 2;
193
+ }
194
+
195
+ for header in [
196
+ "HTTP_ACCEPT_LANGUAGE",
197
+ "HTTP_ACCEPT_ENCODING",
198
+ "HTTP_CONNECTION",
199
+ "HTTP_CACHE_CONTROL",
200
+ ] {
201
+ if has_header(&headers, header) {
202
+ score += 1;
203
+ }
204
+ }
205
+
206
+ if !accept_language.is_empty() && !accept_encoding.is_empty() {
207
+ score += 1;
208
+ }
209
+ if connection == "keep-alive" {
210
+ score += 1;
211
+ }
212
+ if accept.contains("text/html") && accept.contains("application/xml") {
213
+ score += 1;
214
+ }
215
+
216
+ let min_score = min_score.unwrap_or(3);
217
+ if min_score > 0 && score < min_score {
218
+ return Ok(Some(format!("Low header quality score: {}", score)));
219
+ }
220
+
221
+ Ok(None)
222
+ }
223
+
224
+ #[derive(Clone)]
225
+ struct FeatureRecordInput {
226
+ ip: String,
227
+ path_lower: String,
228
+ path_len: usize,
229
+ timestamp: f64,
230
+ response_time: f64,
231
+ status_idx: i32,
232
+ kw_check: bool,
233
+ total_404: i32,
234
+ }
235
+
236
+ impl<'py> FromPyObject<'py> for FeatureRecordInput {
237
+ fn extract(ob: &'py PyAny) -> PyResult<Self> {
238
+ let dict: &PyDict = ob.downcast()?;
239
+
240
+ let get_required = |key: &str| -> PyResult<&PyAny> {
241
+ dict.get_item(key)?
242
+ .ok_or_else(|| PyErr::new::<PyKeyError, _>(key.to_string()))
243
+ };
244
+
245
+ Ok(Self {
246
+ ip: get_required("ip")?.extract()?,
247
+ path_lower: get_required("path_lower")?.extract()?,
248
+ path_len: get_required("path_len")?.extract()?,
249
+ timestamp: get_required("timestamp")?.extract()?,
250
+ response_time: get_required("response_time")?.extract()?,
251
+ status_idx: get_required("status_idx")?.extract()?,
252
+ kw_check: get_required("kw_check")?.extract()?,
253
+ total_404: get_required("total_404")?.extract()?,
254
+ })
255
+ }
256
+ }
257
+
258
+ #[derive(Clone)]
259
+ struct RecentEntryInput {
260
+ path_lower: String,
261
+ timestamp: f64,
262
+ status: i32,
263
+ kw_check: bool,
264
+ }
265
+
266
+ impl<'py> FromPyObject<'py> for RecentEntryInput {
267
+ fn extract(ob: &'py PyAny) -> PyResult<Self> {
268
+ let dict: &PyDict = ob.downcast()?;
269
+
270
+ let get_required = |key: &str| -> PyResult<&PyAny> {
271
+ dict.get_item(key)?
272
+ .ok_or_else(|| PyErr::new::<PyKeyError, _>(key.to_string()))
273
+ };
274
+
275
+ Ok(Self {
276
+ path_lower: get_required("path_lower")?.extract()?,
277
+ timestamp: get_required("timestamp")?.extract()?,
278
+ status: get_required("status")?.extract()?,
279
+ kw_check: get_required("kw_check")?.extract()?,
280
+ })
281
+ }
282
+ }
283
+
284
+ fn build_timestamp_index(records: &[FeatureRecordInput]) -> HashMap<String, Vec<f64>> {
285
+ let mut map: HashMap<String, Vec<f64>> = HashMap::new();
286
+ for rec in records {
287
+ map.entry(rec.ip.clone())
288
+ .or_default()
289
+ .push(rec.timestamp);
290
+ }
291
+ for timestamps in map.values_mut() {
292
+ timestamps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
293
+ }
294
+ map
295
+ }
296
+
297
+ fn count_burst(timestamps: Option<&Vec<f64>>, current: f64) -> i32 {
298
+ if let Some(ts) = timestamps {
299
+ let min_ts = current - 10.0;
300
+ ts.iter()
301
+ .filter(|value| **value >= min_ts && **value <= current)
302
+ .count() as i32
303
+ } else {
304
+ 0
305
+ }
306
+ }
307
+
308
+ fn keyword_hits(path_lower: &str, keywords: &[String], enabled: bool) -> i32 {
309
+ if !enabled {
310
+ return 0;
311
+ }
312
+ keywords
313
+ .iter()
314
+ .filter(|kw| path_lower.contains(kw.as_str()))
315
+ .count() as i32
316
+ }
317
+
318
+ fn lower_bound(values: &[f64], target: f64) -> usize {
319
+ let mut left = 0usize;
320
+ let mut right = values.len();
321
+ while left < right {
322
+ let mid = (left + right) / 2;
323
+ if values[mid] < target {
324
+ left = mid + 1;
325
+ } else {
326
+ right = mid;
327
+ }
328
+ }
329
+ left
330
+ }
331
+
332
+ fn upper_bound(values: &[f64], target: f64) -> usize {
333
+ let mut left = 0usize;
334
+ let mut right = values.len();
335
+ while left < right {
336
+ let mid = (left + right) / 2;
337
+ if values[mid] <= target {
338
+ left = mid + 1;
339
+ } else {
340
+ right = mid;
341
+ }
342
+ }
343
+ left
344
+ }
345
+
346
+ fn is_scanning_path(path_lower: &str) -> bool {
347
+ let scanning_patterns = [
348
+ "wp-admin",
349
+ "wp-content",
350
+ "wp-includes",
351
+ "wp-config",
352
+ "xmlrpc.php",
353
+ "admin",
354
+ "phpmyadmin",
355
+ "adminer",
356
+ "config",
357
+ "configuration",
358
+ "settings",
359
+ "setup",
360
+ "install",
361
+ "installer",
362
+ "backup",
363
+ "database",
364
+ "db",
365
+ "mysql",
366
+ "sql",
367
+ "dump",
368
+ ".env",
369
+ ".git",
370
+ ".htaccess",
371
+ ".htpasswd",
372
+ "passwd",
373
+ "shadow",
374
+ "robots.txt",
375
+ "sitemap.xml",
376
+ "cgi-bin",
377
+ "scripts",
378
+ "shell",
379
+ "cmd",
380
+ "exec",
381
+ ".php",
382
+ ".asp",
383
+ ".aspx",
384
+ ".jsp",
385
+ ".cgi",
386
+ ".pl",
387
+ ];
388
+
389
+ if scanning_patterns.iter().any(|pat| path_lower.contains(pat)) {
390
+ return true;
391
+ }
392
+ if path_lower.contains("../") || path_lower.contains("..\\") {
393
+ return true;
394
+ }
395
+ let encoded = ["%2e%2e", "%252e", "%c0%ae"];
396
+ if encoded.iter().any(|enc| path_lower.contains(enc)) {
397
+ return true;
398
+ }
399
+ false
400
+ }
401
+
402
+ #[pyfunction]
403
+ fn extract_features<'py>(
404
+ py: Python<'py>,
405
+ records: Vec<FeatureRecordInput>,
406
+ static_keywords: Vec<String>,
407
+ ) -> PyResult<Vec<Py<PyDict>>> {
408
+ if records.is_empty() {
409
+ return Ok(Vec::new());
410
+ }
411
+
412
+ let keywords: Vec<String> = static_keywords
413
+ .into_iter()
414
+ .map(|kw| kw.to_lowercase())
415
+ .collect();
416
+
417
+ let timestamp_index = build_timestamp_index(&records);
418
+ let mut output = Vec::with_capacity(records.len());
419
+
420
+ for rec in records.into_iter() {
421
+ let timestamps = timestamp_index.get(&rec.ip);
422
+ let burst = count_burst(timestamps, rec.timestamp);
423
+ let kw = keyword_hits(&rec.path_lower, &keywords, rec.kw_check);
424
+
425
+ let feature = PyDict::new_bound(py);
426
+ feature.set_item("ip", rec.ip.clone())?;
427
+ feature.set_item("path_len", rec.path_len)?;
428
+ feature.set_item("kw_hits", kw)?;
429
+ feature.set_item("resp_time", rec.response_time)?;
430
+ feature.set_item("status_idx", rec.status_idx)?;
431
+ feature.set_item("burst_count", burst)?;
432
+ feature.set_item("total_404", rec.total_404)?;
433
+ output.push(feature.into());
434
+ }
435
+
436
+ Ok(output)
437
+ }
438
+
439
+ #[pyfunction]
440
+ fn analyze_recent_behavior<'py>(
441
+ py: Python<'py>,
442
+ entries: Vec<RecentEntryInput>,
443
+ static_keywords: Vec<String>,
444
+ ) -> PyResult<Option<Py<PyDict>>> {
445
+ if entries.is_empty() {
446
+ return Ok(None);
447
+ }
448
+
449
+ let keywords: Vec<String> = static_keywords
450
+ .into_iter()
451
+ .map(|kw| kw.to_lowercase())
452
+ .collect();
453
+ let mut timestamps: Vec<f64> = entries.iter().map(|e| e.timestamp).collect();
454
+ timestamps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
455
+
456
+ let mut total_kw_hits = 0f64;
457
+ let mut total_burst = 0f64;
458
+ let mut max_404s = 0i32;
459
+ let mut scanning_404s = 0i32;
460
+
461
+ for entry in entries.iter() {
462
+ if entry.status == 404 {
463
+ max_404s += 1;
464
+ if is_scanning_path(&entry.path_lower) {
465
+ scanning_404s += 1;
466
+ }
467
+ }
468
+ let kw = keyword_hits(&entry.path_lower, &keywords, entry.kw_check);
469
+ total_kw_hits += kw as f64;
470
+
471
+ let lower = lower_bound(&timestamps, entry.timestamp - 10.0);
472
+ let upper = upper_bound(&timestamps, entry.timestamp + 10.0);
473
+ let burst = (upper.saturating_sub(lower)) as i32;
474
+ total_burst += burst as f64;
475
+ }
476
+
477
+ let total_requests = entries.len() as i32;
478
+ let avg_kw_hits = if total_requests > 0 {
479
+ total_kw_hits / total_requests as f64
480
+ } else {
481
+ 0.0
482
+ };
483
+ let avg_burst = if total_requests > 0 {
484
+ total_burst / total_requests as f64
485
+ } else {
486
+ 0.0
487
+ };
488
+ let legitimate_404s = (max_404s - scanning_404s).max(0);
489
+
490
+ let mut should_block = true;
491
+ if max_404s == 0 && avg_kw_hits == 0.0 && scanning_404s == 0 {
492
+ should_block = false;
493
+ } else if avg_kw_hits < 3.0
494
+ && scanning_404s < 5
495
+ && legitimate_404s < 20
496
+ && avg_burst < 25.0
497
+ && total_requests < 150
498
+ {
499
+ should_block = false;
500
+ }
501
+
502
+ let result = PyDict::new_bound(py);
503
+ result.set_item("avg_kw_hits", avg_kw_hits)?;
504
+ result.set_item("max_404s", max_404s)?;
505
+ result.set_item("avg_burst", avg_burst)?;
506
+ result.set_item("total_requests", total_requests)?;
507
+ result.set_item("scanning_404s", scanning_404s)?;
508
+ result.set_item("legitimate_404s", legitimate_404s)?;
509
+ result.set_item("should_block", should_block)?;
510
+
511
+ Ok(Some(result.into()))
512
+ }
513
+
514
+ #[pymodule]
515
+ fn aiwaf_rust(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
516
+ m.add_function(wrap_pyfunction!(validate_headers, m)?)?;
517
+ m.add_function(wrap_pyfunction!(validate_headers_with_config, m)?)?;
518
+ m.add_function(wrap_pyfunction!(extract_features, m)?)?;
519
+ m.add_function(wrap_pyfunction!(analyze_recent_behavior, m)?)?;
520
+ Ok(())
521
+ }
522
+
523
+ #[cfg(test)]
524
+ mod tests {
525
+ use super::*;
526
+ use pyo3::types::PyDict;
527
+
528
+ fn dict_from_pairs<'a>(
529
+ py: Python<'a>,
530
+ pairs: &'a [(&'a str, &'a str)],
531
+ ) -> Bound<'a, PyDict> {
532
+ let dict = PyDict::new_bound(py);
533
+ for (k, v) in pairs {
534
+ dict.set_item(*k, *v).unwrap();
535
+ }
536
+ dict
537
+ }
538
+
539
+ #[test]
540
+ fn validate_headers_blocks_missing_required() {
541
+ Python::with_gil(|py| {
542
+ let headers = dict_from_pairs(py, &[("HTTP_USER_AGENT", "Mozilla/5.0")]);
543
+ let result = validate_headers(headers).unwrap();
544
+ assert!(matches!(result, Some(msg) if msg.contains("Missing required headers")));
545
+ });
546
+ }
547
+
548
+ #[test]
549
+ fn validate_headers_with_config_allows_empty_required() {
550
+ Python::with_gil(|py| {
551
+ let headers = dict_from_pairs(py, &[("HTTP_USER_AGENT", "EmailScanner/1.0")]);
552
+ let result = validate_headers_with_config(headers, Some(vec![]), Some(0)).unwrap();
553
+ assert!(result.is_none());
554
+ });
555
+ }
556
+
557
+ #[test]
558
+ fn validate_headers_with_config_respects_required() {
559
+ Python::with_gil(|py| {
560
+ let headers = dict_from_pairs(py, &[("HTTP_USER_AGENT", "Mozilla/5.0")]);
561
+ let required = vec!["HTTP_USER_AGENT".to_string(), "HTTP_ACCEPT".to_string()];
562
+ let result = validate_headers_with_config(headers, Some(required), Some(3)).unwrap();
563
+ assert!(matches!(result, Some(msg) if msg.contains("Missing required headers")));
564
+ });
565
+ }
566
+
567
+ #[test]
568
+ fn validate_headers_blocks_suspicious_user_agent() {
569
+ Python::with_gil(|py| {
570
+ let headers = dict_from_pairs(py, &[
571
+ ("HTTP_USER_AGENT", "python-requests/2.25.1"),
572
+ ("HTTP_ACCEPT", "*/*"),
573
+ ]);
574
+ let result = validate_headers(headers).unwrap();
575
+ assert!(matches!(result, Some(msg) if msg.contains("Suspicious user agent")));
576
+ });
577
+ }
578
+
579
+ #[test]
580
+ fn validate_headers_blocks_suspicious_combinations() {
581
+ Python::with_gil(|py| {
582
+ let headers = dict_from_pairs(py, &[
583
+ ("HTTP_USER_AGENT", "Mozilla/4.0"),
584
+ ("HTTP_ACCEPT", "text/html"),
585
+ ("SERVER_PROTOCOL", "HTTP/2"),
586
+ ]);
587
+ let result = validate_headers(headers).unwrap();
588
+ assert!(matches!(result, Some(msg) if msg.contains("Suspicious headers")));
589
+ });
590
+ }
591
+
592
+ #[test]
593
+ fn validate_headers_allows_legit_browser() {
594
+ Python::with_gil(|py| {
595
+ let headers = dict_from_pairs(py, &[
596
+ ("HTTP_USER_AGENT", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"),
597
+ ("HTTP_ACCEPT", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
598
+ ("HTTP_ACCEPT_LANGUAGE", "en-US,en;q=0.5"),
599
+ ("HTTP_ACCEPT_ENCODING", "gzip, deflate"),
600
+ ("HTTP_CONNECTION", "keep-alive"),
601
+ ]);
602
+ let result = validate_headers(headers).unwrap();
603
+ assert!(result.is_none());
604
+ });
605
+ }
606
+
607
+ #[test]
608
+ fn validate_headers_allows_legit_bot() {
609
+ Python::with_gil(|py| {
610
+ let headers = dict_from_pairs(py, &[
611
+ ("HTTP_USER_AGENT", "Googlebot/2.1 (+http://www.google.com/bot.html)"),
612
+ ("HTTP_ACCEPT", "*/*"),
613
+ ("HTTP_ACCEPT_LANGUAGE", "en-US"),
614
+ ]);
615
+ let result = validate_headers(headers).unwrap();
616
+ assert!(result.is_none());
617
+ });
618
+ }
619
+
620
+ #[test]
621
+ fn validate_headers_blocks_accept_star_missing_lang_encoding() {
622
+ Python::with_gil(|py| {
623
+ let headers = dict_from_pairs(py, &[
624
+ ("HTTP_USER_AGENT", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"),
625
+ ("HTTP_ACCEPT", "*/*"),
626
+ ]);
627
+ let result = validate_headers(headers).unwrap();
628
+ assert!(matches!(result, Some(msg) if msg.contains("Generic Accept header")));
629
+ });
630
+ }
631
+
632
+ #[test]
633
+ fn validate_headers_blocks_http10_chrome() {
634
+ Python::with_gil(|py| {
635
+ let headers = dict_from_pairs(py, &[
636
+ ("HTTP_USER_AGENT", "Mozilla/5.0 Chrome/120.0.0.0"),
637
+ ("HTTP_ACCEPT", "text/html"),
638
+ ("HTTP_ACCEPT_LANGUAGE", "en-US"),
639
+ ("SERVER_PROTOCOL", "HTTP/1.0"),
640
+ ]);
641
+ let result = validate_headers(headers).unwrap();
642
+ assert!(matches!(result, Some(msg) if msg.contains("HTTP/1.0")));
643
+ });
644
+ }
645
+ }
@@ -0,0 +1,94 @@
1
+ name: Build and Publish Rust Package
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build-wheels:
13
+ name: Build wheels (${{ matrix.os }} / py${{ matrix.python }})
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, macos-latest, windows-latest]
19
+ python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - id: setup-python
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python }}
28
+
29
+ - name: Build wheel
30
+ uses: PyO3/maturin-action@v1
31
+ env:
32
+ PYTHON_SYS_EXECUTABLE: ${{ steps.setup-python.outputs.python-path }}
33
+ PYO3_PYTHON: ${{ steps.setup-python.outputs.python-path }}
34
+ with:
35
+ command: build
36
+ args: --release --out dist
37
+ sccache: true
38
+
39
+ - name: Upload wheels
40
+ uses: actions/upload-artifact@v4
41
+ with:
42
+ name: rust-wheels-${{ matrix.os }}-py${{ matrix.python }}
43
+ path: dist
44
+
45
+ build-sdist:
46
+ name: Build sdist
47
+ runs-on: ubuntu-latest
48
+
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+
52
+ - uses: actions/setup-python@v5
53
+ with:
54
+ python-version: "3.12"
55
+
56
+ - name: Build sdist
57
+ uses: PyO3/maturin-action@v1
58
+ with:
59
+ command: sdist
60
+ args: --out dist
61
+
62
+ - name: Upload sdist
63
+ uses: actions/upload-artifact@v4
64
+ with:
65
+ name: rust-sdist
66
+ path: dist
67
+
68
+ pypi-publish:
69
+ name: Publish to PyPI
70
+ runs-on: ubuntu-latest
71
+ needs: [build-wheels, build-sdist]
72
+ permissions:
73
+ id-token: write
74
+ environment:
75
+ name: pypi
76
+
77
+ steps:
78
+ - name: Download wheels
79
+ uses: actions/download-artifact@v4
80
+ with:
81
+ pattern: rust-wheels-*
82
+ path: dist
83
+ merge-multiple: true
84
+
85
+ - name: Download sdist
86
+ uses: actions/download-artifact@v4
87
+ with:
88
+ name: rust-sdist
89
+ path: dist
90
+
91
+ - name: Publish
92
+ uses: pypa/gh-action-pypi-publish@release/v1
93
+ with:
94
+ packages-dir: dist/
@@ -0,0 +1,92 @@
1
+ name: Build and Publish Rust Extension
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build-wheels:
13
+ name: Build rust wheels (${{ matrix.os }})
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, macos-latest, windows-latest]
19
+ python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - id: setup-python
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python }}
28
+
29
+ - name: Build wheels
30
+ uses: PyO3/maturin-action@v1
31
+ env:
32
+ PYTHON_SYS_EXECUTABLE: ${{ steps.setup-python.outputs.python-path }}
33
+ PYO3_PYTHON: ${{ steps.setup-python.outputs.python-path }}
34
+ with:
35
+ command: build
36
+ args: --release -m Cargo.toml --out dist
37
+ sccache: true
38
+
39
+ - name: Upload wheels
40
+ uses: actions/upload-artifact@v4
41
+ with:
42
+ name: rust-wheels-${{ matrix.os }}-py${{ matrix.python }}
43
+ path: dist
44
+
45
+ build-sdist:
46
+ name: Build rust sdist
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+
51
+ - uses: actions/setup-python@v5
52
+ with:
53
+ python-version: "3.10"
54
+
55
+ - name: Build sdist
56
+ uses: PyO3/maturin-action@v1
57
+ with:
58
+ command: sdist
59
+ args: -m Cargo.toml --out dist
60
+
61
+ - name: Upload sdist
62
+ uses: actions/upload-artifact@v4
63
+ with:
64
+ name: rust-sdist
65
+ path: dist
66
+
67
+ pypi-publish:
68
+ name: Publish rust package to PyPI
69
+ runs-on: ubuntu-latest
70
+ needs: [build-wheels, build-sdist]
71
+ permissions:
72
+ id-token: write
73
+ environment:
74
+ name: pypi
75
+ steps:
76
+ - name: Download wheels
77
+ uses: actions/download-artifact@v4
78
+ with:
79
+ pattern: rust-wheels-*
80
+ path: dist
81
+ merge-multiple: true
82
+
83
+ - name: Download sdist
84
+ uses: actions/download-artifact@v4
85
+ with:
86
+ name: rust-sdist
87
+ path: dist
88
+
89
+ - name: Publish
90
+ uses: pypa/gh-action-pypi-publish@release/v1
91
+ with:
92
+ packages-dir: dist/