saltminer 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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: saltminer
3
+ Version: 0.1.0
4
+ Summary: Identify and audit password hashes, offline.
5
+ License: MIT
6
+ Requires-Python: >=3.13
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.7,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "saltminer"
7
+ version = "0.1.0"
8
+ description = "Identify and audit password hashes, offline."
9
+ requires-python = ">=3.13"
10
+ license = { text = "MIT" }
11
+
12
+ [tool.maturin]
13
+ module-name = "saltminer"
14
+ manifest-path = "saltminer-py/Cargo.toml"
@@ -0,0 +1,10 @@
1
+ [package]
2
+ name = "saltminer-core"
3
+ version = "0.0.1"
4
+ edition = "2024"
5
+ license = "MIT"
6
+
7
+ [dependencies]
8
+
9
+ [dev-dependencies]
10
+ proptest = "1.11.0"
@@ -0,0 +1,549 @@
1
+ //! Saltminer core — the identification and audit engine.
2
+
3
+ /// How sure we are about a single guess.
4
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5
+ pub enum Confidence {
6
+ High,
7
+ Medium,
8
+ Low,
9
+ }
10
+
11
+ /// One possible identification of a hash string.
12
+ #[derive(Debug, Clone, PartialEq, Eq)]
13
+ pub struct Candidate {
14
+ pub algorithm: String,
15
+ pub confidence: Confidence,
16
+ pub reason: String,
17
+ }
18
+
19
+ /// Known hash prefixes: (prefix, algorithm, note).
20
+ const PREFIX_RULES: &[(&str, &str, &str)] = &[
21
+ (
22
+ "$argon2id$",
23
+ "Argon2id",
24
+ "modern PHC string, current standard",
25
+ ),
26
+ (
27
+ "$argon2i$",
28
+ "Argon2i",
29
+ "PHC string, side-channel-resistant variant",
30
+ ),
31
+ ("$argon2d$", "Argon2d", "PHC string, GPU-resistant variant"),
32
+ ("$2b$", "bcrypt", "bcrypt PHC string, 2b variant"),
33
+ ("$2y$", "bcrypt", "bcrypt PHC string, 2y variant (PHP)"),
34
+ ("$2a$", "bcrypt", "bcrypt PHC string, 2a variant (legacy)"),
35
+ ("$6$", "SHA-512 crypt", "Unix crypt(3) using SHA-512"),
36
+ ("$5$", "SHA-256 crypt", "Unix crypt(3) using SHA-256"),
37
+ ("$1$", "MD5 crypt", "Unix crypt(3) using MD5 (legacy)"),
38
+ ("$apr1$", "Apache MD5-crypt", "Apache htpasswd MD5 variant"),
39
+ (
40
+ "pbkdf2_sha256$",
41
+ "Django PBKDF2-SHA256",
42
+ "Django default password hash",
43
+ ),
44
+ ];
45
+
46
+ /// True if the text is non-empty and every character is a hex digit.
47
+ fn is_hex(text: &str) -> bool {
48
+ !text.is_empty() && text.chars().all(|c| c.is_ascii_hexdigit())
49
+ }
50
+
51
+ /// True for MySQL5: a `*` followed by exactly 40 uppercase hex chars.
52
+ fn is_mysql5(text: &str) -> bool {
53
+ if text.len() != 41 || !text.starts_with('*') {
54
+ return false;
55
+ }
56
+ text[1..]
57
+ .chars()
58
+ .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase())
59
+ }
60
+
61
+ /// Algorithms that produce a hex string of this length, most common first.
62
+ fn length_rules(len: usize) -> &'static [&'static str] {
63
+ match len {
64
+ 32 => &["MD5", "NTLM", "MD4", "RIPEMD-128"],
65
+ 40 => &["SHA-1", "RIPEMD-160"],
66
+ 56 => &["SHA-224", "SHA3-224"],
67
+ 64 => &["SHA-256", "SHA3-256", "BLAKE2s-256"],
68
+ 96 => &["SHA-384", "SHA3-384"],
69
+ 128 => &["SHA-512", "SHA3-512", "BLAKE2b-512"],
70
+ _ => &[],
71
+ }
72
+ }
73
+
74
+ /// A parsed PHC-style hash string, broken into its parts.
75
+ #[derive(Debug, Clone, PartialEq, Eq)]
76
+ pub struct PhcHash {
77
+ pub id: String,
78
+ pub params: Vec<(String, String)>,
79
+ pub segments: Vec<String>,
80
+ }
81
+
82
+ impl PhcHash {
83
+ /// Get the value of a named parameter, if present.
84
+ pub fn param(&self, key: &str) -> Option<&str> {
85
+ self.params
86
+ .iter()
87
+ .find(|(k, _)| k == key)
88
+ .map(|(_, v)| v.as_str())
89
+ }
90
+ }
91
+
92
+ /// Parse a PHC-style string like `$argon2id$v=19$m=65536,t=3,p=4$salt$hash`.
93
+ /// Returns `None` if the input has no `$`-separated structure.
94
+ pub fn parse_phc(input: &str) -> Option<PhcHash> {
95
+ let text = input.trim();
96
+ let body = text.strip_prefix('$').unwrap_or(text);
97
+ let segments: Vec<String> = body.split('$').map(str::to_string).collect();
98
+
99
+ if segments.len() < 2 || segments[0].is_empty() {
100
+ return None;
101
+ }
102
+
103
+ let id = segments[0].clone();
104
+ let mut params = Vec::new();
105
+ for seg in &segments[1..] {
106
+ for pair in seg.split(',') {
107
+ if let Some((key, value)) = pair.split_once('=') {
108
+ params.push((key.to_string(), value.to_string()));
109
+ }
110
+ }
111
+ }
112
+
113
+ Some(PhcHash {
114
+ id,
115
+ params,
116
+ segments,
117
+ })
118
+ }
119
+
120
+ /// A security verdict on how a hash was produced.
121
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
122
+ pub enum Verdict {
123
+ Secure,
124
+ WeakParams,
125
+ Deprecated,
126
+ Broken,
127
+ }
128
+
129
+ /// The result of auditing a hash's algorithm and cost parameters.
130
+ #[derive(Debug, Clone, PartialEq, Eq)]
131
+ pub struct AuditReport {
132
+ pub algorithm: String,
133
+ pub verdict: Verdict,
134
+ pub detail: String,
135
+ }
136
+
137
+ // OWASP 2026 password-storage minimums.
138
+ const ARGON2_MIN_MEMORY_KIB: u32 = 19_456; // 19 MiB
139
+ const BCRYPT_MIN_COST: u32 = 10;
140
+ const PBKDF2_MIN_ITERATIONS: u32 = 600_000;
141
+
142
+ /// Judge a hash against OWASP 2026 password-storage guidance.
143
+ pub fn audit(input: &str) -> Option<AuditReport> {
144
+ if let Some(phc) = parse_phc(input) {
145
+ return audit_phc(&phc);
146
+ }
147
+
148
+ // Not a PHC string: judge common raw hashes by what identify() sees.
149
+ let candidates = identify(input);
150
+ let first = candidates.first()?;
151
+ const BROKEN: &[&str] = &["MD5", "SHA-1", "NTLM", "MySQL5", "MySQL323", "MD4"];
152
+ if BROKEN.contains(&first.algorithm.as_str()) {
153
+ return Some(AuditReport {
154
+ algorithm: first.algorithm.clone(),
155
+ verdict: Verdict::Broken,
156
+ detail: "fast, unsalted hash — not safe for passwords; use Argon2id".to_string(),
157
+ });
158
+ }
159
+
160
+ None
161
+ }
162
+
163
+ /// Rate a parsed PHC hash. Returns None for formats we do not rate.
164
+ fn audit_phc(phc: &PhcHash) -> Option<AuditReport> {
165
+ let report = match phc.id.as_str() {
166
+ "argon2id" | "argon2i" | "argon2d" => {
167
+ let memory = phc
168
+ .param("m")
169
+ .and_then(|v| v.parse::<u32>().ok())
170
+ .unwrap_or(0);
171
+ let (verdict, detail) = if memory < ARGON2_MIN_MEMORY_KIB {
172
+ (
173
+ Verdict::WeakParams,
174
+ format!("memory {memory} KiB is below the 19 MiB minimum"),
175
+ )
176
+ } else {
177
+ (
178
+ Verdict::Secure,
179
+ format!("memory {memory} KiB meets current guidance"),
180
+ )
181
+ };
182
+ AuditReport {
183
+ algorithm: "Argon2".to_string(),
184
+ verdict,
185
+ detail,
186
+ }
187
+ }
188
+ "2a" | "2b" | "2y" | "2x" => {
189
+ let cost = phc
190
+ .segments
191
+ .get(1)
192
+ .and_then(|v| v.parse::<u32>().ok())
193
+ .unwrap_or(0);
194
+ let (verdict, detail) = if cost < BCRYPT_MIN_COST {
195
+ (
196
+ Verdict::WeakParams,
197
+ format!("cost {cost} is below the minimum of {BCRYPT_MIN_COST}"),
198
+ )
199
+ } else {
200
+ (
201
+ Verdict::Secure,
202
+ format!("cost {cost} meets current guidance"),
203
+ )
204
+ };
205
+ AuditReport {
206
+ algorithm: "bcrypt".to_string(),
207
+ verdict,
208
+ detail,
209
+ }
210
+ }
211
+ "pbkdf2_sha256" => {
212
+ let iters = phc
213
+ .segments
214
+ .get(1)
215
+ .and_then(|v| v.parse::<u32>().ok())
216
+ .unwrap_or(0);
217
+ let (verdict, detail) = if iters < PBKDF2_MIN_ITERATIONS {
218
+ (
219
+ Verdict::WeakParams,
220
+ format!("{iters} iterations is below the 600,000 minimum"),
221
+ )
222
+ } else {
223
+ (
224
+ Verdict::Secure,
225
+ format!("{iters} iterations meets current guidance"),
226
+ )
227
+ };
228
+ AuditReport {
229
+ algorithm: "PBKDF2-SHA256".to_string(),
230
+ verdict,
231
+ detail,
232
+ }
233
+ }
234
+ "1" | "apr1" => AuditReport {
235
+ algorithm: "MD5-based crypt".to_string(),
236
+ verdict: Verdict::Deprecated,
237
+ detail: "MD5-based — obsolete; migrate to Argon2id or bcrypt".to_string(),
238
+ },
239
+ "6" => AuditReport {
240
+ algorithm: "SHA-512 crypt".to_string(),
241
+ verdict: Verdict::Secure,
242
+ detail: "SHA-512 crypt is acceptable when rounds are high enough".to_string(),
243
+ },
244
+ _ => return None,
245
+ };
246
+ Some(report)
247
+ }
248
+
249
+ /// Identify a hash string. Returns a ranked list of candidates.
250
+ pub fn identify(input: &str) -> Vec<Candidate> {
251
+ let trimmed = input.trim();
252
+
253
+ if trimmed.is_empty() {
254
+ return Vec::new();
255
+ }
256
+
257
+ for &(prefix, algorithm, note) in PREFIX_RULES {
258
+ if trimmed.starts_with(prefix) {
259
+ return vec![Candidate {
260
+ algorithm: algorithm.to_string(),
261
+ confidence: Confidence::High,
262
+ reason: format!("prefix {prefix} — {note}"),
263
+ }];
264
+ }
265
+ }
266
+
267
+ // MySQL5
268
+ if is_mysql5(trimmed) {
269
+ return vec![Candidate {
270
+ algorithm: "MySQL5".to_string(),
271
+ confidence: Confidence::High,
272
+ reason: "`*` + 40 uppercase hex chars".to_string(),
273
+ }];
274
+ }
275
+
276
+ // pwdump / NTLM (Windows SAM): user:rid:lm(32 hex):nt(32 hex):::
277
+ if trimmed.ends_with(":::") {
278
+ let parts: Vec<&str> = trimmed.split(':').collect();
279
+ if parts.len() == 7
280
+ && parts[1].chars().all(|c| c.is_ascii_digit())
281
+ && parts[2].len() == 32
282
+ && is_hex(parts[2])
283
+ && parts[3].len() == 32
284
+ && is_hex(parts[3])
285
+ {
286
+ return vec![Candidate {
287
+ algorithm: "NTLM".to_string(),
288
+ confidence: Confidence::High,
289
+ reason: "pwdump line — the NT hash is NTLM".to_string(),
290
+ }];
291
+ }
292
+ }
293
+
294
+ // NetNTLMv2 / NetNTLMv1: colon-delimited challenge-response records
295
+ if trimmed.contains("::") && trimmed.matches(':').count() >= 4 {
296
+ let parts: Vec<&str> = trimmed.split(':').collect();
297
+ if parts.len() >= 6 && parts[4].len() == 32 && is_hex(parts[4]) {
298
+ return vec![Candidate {
299
+ algorithm: "NetNTLMv2".to_string(),
300
+ confidence: Confidence::High,
301
+ reason: "user::domain:challenge:hmac(32 hex):blob shape".to_string(),
302
+ }];
303
+ }
304
+ if parts.len() >= 6 && parts[3].len() == 48 && is_hex(parts[3]) {
305
+ return vec![Candidate {
306
+ algorithm: "NetNTLMv1".to_string(),
307
+ confidence: Confidence::High,
308
+ reason: "user::domain:lm(48 hex):nt(48 hex):challenge shape".to_string(),
309
+ }];
310
+ }
311
+ }
312
+
313
+ if is_hex(trimmed) {
314
+ let algorithms = length_rules(trimmed.len());
315
+ let mut candidates = Vec::new();
316
+ for (index, algorithm) in algorithms.iter().enumerate() {
317
+ let confidence = if index == 0 {
318
+ Confidence::Medium
319
+ } else {
320
+ Confidence::Low
321
+ };
322
+ let label = if index == 0 {
323
+ "most likely at this length"
324
+ } else {
325
+ "also possible at this length"
326
+ };
327
+ candidates.push(Candidate {
328
+ algorithm: algorithm.to_string(),
329
+ confidence,
330
+ reason: format!("{} hex chars — {label}", trimmed.len()),
331
+ });
332
+ }
333
+ return candidates;
334
+ }
335
+
336
+ // Not hashes, but say what they actually are.
337
+ if trimmed.starts_with("eyJ") {
338
+ return vec![Candidate {
339
+ algorithm: "JWT (not a hash)".to_string(),
340
+ confidence: Confidence::Low,
341
+ reason: "leading `eyJ` is base64 of `{\"` — a JWT, not a hash".to_string(),
342
+ }];
343
+ }
344
+
345
+ if trimmed.len() > 8 && trimmed.contains(['+', '/', '=']) {
346
+ return vec![Candidate {
347
+ algorithm: "Base64 blob (not a hash)".to_string(),
348
+ confidence: Confidence::Low,
349
+ reason: "contains base64-only chars (`+`, `/`, `=`)".to_string(),
350
+ }];
351
+ }
352
+
353
+ Vec::new()
354
+ }
355
+
356
+ #[cfg(test)]
357
+ mod tests {
358
+ use super::*;
359
+
360
+ #[test]
361
+ fn empty_input_returns_no_candidates() {
362
+ assert!(identify("").is_empty());
363
+ }
364
+
365
+ #[test]
366
+ fn bcrypt_prefix_is_high_confidence() {
367
+ let result = identify("$2b$12$abcdefghijklmnopqrstuv");
368
+ assert_eq!(result[0].algorithm, "bcrypt");
369
+ assert_eq!(result[0].confidence, Confidence::High);
370
+ }
371
+
372
+ #[test]
373
+ fn argon2id_prefix_is_recognized() {
374
+ let result = identify("$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA");
375
+ assert_eq!(result[0].algorithm, "Argon2id");
376
+ }
377
+
378
+ #[test]
379
+ fn md5_length_is_medium_confidence() {
380
+ let result = identify("5f4dcc3b5aa765d61d8327deb882cf99");
381
+ assert_eq!(result[0].algorithm, "MD5");
382
+ assert_eq!(result[0].confidence, Confidence::Medium);
383
+ let names: Vec<&str> = result.iter().map(|c| c.algorithm.as_str()).collect();
384
+ assert!(names.contains(&"NTLM"));
385
+ }
386
+
387
+ #[test]
388
+ fn sha256_length_is_recognized() {
389
+ let hash = "a".repeat(64);
390
+ let result = identify(&hash);
391
+ assert_eq!(result[0].algorithm, "SHA-256");
392
+ }
393
+
394
+ #[test]
395
+ fn mysql5_is_recognized() {
396
+ let result = identify("*A4B6157319038724E3560894F7F932C8886EBFCF");
397
+ assert_eq!(result[0].algorithm, "MySQL5");
398
+ assert_eq!(result[0].confidence, Confidence::High);
399
+ }
400
+
401
+ #[test]
402
+ fn mysql5_rejects_lowercase_body() {
403
+ let result = identify("*a4b6157319038724e3560894f7f932c8886ebfcf");
404
+ let claimed_mysql5 = !result.is_empty() && result[0].algorithm == "MySQL5";
405
+ assert!(!claimed_mysql5);
406
+ }
407
+
408
+ #[test]
409
+ fn netntlmv2_is_recognized() {
410
+ let sample = format!(
411
+ "alice::CORP:1122334455667788:{}:{}",
412
+ "a".repeat(32),
413
+ "b".repeat(64)
414
+ );
415
+ let result = identify(&sample);
416
+ assert_eq!(result[0].algorithm, "NetNTLMv2");
417
+ }
418
+
419
+ #[test]
420
+ fn pwdump_line_is_ntlm() {
421
+ let sample = "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::";
422
+ let result = identify(sample);
423
+ assert_eq!(result[0].algorithm, "NTLM");
424
+ }
425
+
426
+ #[test]
427
+ fn jwt_is_flagged_as_not_a_hash() {
428
+ let result = identify("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig");
429
+ assert!(result[0].algorithm.contains("JWT"));
430
+ assert_eq!(result[0].confidence, Confidence::Low);
431
+ }
432
+
433
+ #[test]
434
+ fn base64_blob_is_flagged_as_not_a_hash() {
435
+ let result = identify("VGhpcyBpcyBub3QgYSBoYXNoLg==");
436
+ assert!(result[0].algorithm.contains("Base64"));
437
+ }
438
+
439
+ #[test]
440
+ fn unknown_input_returns_empty() {
441
+ assert!(identify("just some random text").is_empty());
442
+ }
443
+
444
+ #[test]
445
+ fn sha1_length_is_recognized() {
446
+ let result = identify(&"a".repeat(40));
447
+ assert_eq!(result[0].algorithm, "SHA-1");
448
+ }
449
+
450
+ #[test]
451
+ fn netntlmv1_is_recognized() {
452
+ let sample = format!(
453
+ "alice::CORP:{}:{}:1122334455667788",
454
+ "a".repeat(48),
455
+ "b".repeat(48)
456
+ );
457
+ let result = identify(&sample);
458
+ assert_eq!(result[0].algorithm, "NetNTLMv1");
459
+ }
460
+
461
+ #[test]
462
+ fn whitespace_is_trimmed() {
463
+ let result = identify(" 5f4dcc3b5aa765d61d8327deb882cf99\n");
464
+ assert_eq!(result[0].algorithm, "MD5");
465
+ }
466
+
467
+ #[test]
468
+ fn parses_argon2id_params() {
469
+ let phc = parse_phc("$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA").unwrap();
470
+ assert_eq!(phc.id, "argon2id");
471
+ assert_eq!(phc.param("m"), Some("65536"));
472
+ assert_eq!(phc.param("t"), Some("3"));
473
+ assert_eq!(phc.param("p"), Some("4"));
474
+ }
475
+
476
+ #[test]
477
+ fn parses_bcrypt_cost_segment() {
478
+ let phc = parse_phc("$2b$12$abcdefghijklmnopqrstuv").unwrap();
479
+ assert_eq!(phc.id, "2b");
480
+ assert_eq!(phc.segments[1], "12");
481
+ }
482
+
483
+ #[test]
484
+ fn non_phc_returns_none() {
485
+ assert!(parse_phc("5f4dcc3b5aa765d61d8327deb882cf99").is_none());
486
+ assert!(parse_phc("hello").is_none());
487
+ }
488
+
489
+ #[test]
490
+ fn strong_argon2id_is_secure() {
491
+ let report = audit("$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA").unwrap();
492
+ assert_eq!(report.verdict, Verdict::Secure);
493
+ }
494
+
495
+ #[test]
496
+ fn weak_argon2id_memory_is_flagged() {
497
+ let report = audit("$argon2id$v=19$m=1024,t=1,p=1$c2FsdA$aGFzaA").unwrap();
498
+ assert_eq!(report.verdict, Verdict::WeakParams);
499
+ }
500
+
501
+ #[test]
502
+ fn low_bcrypt_cost_is_weak() {
503
+ let report = audit("$2b$04$abcdefghijklmnopqrstuv").unwrap();
504
+ assert_eq!(report.verdict, Verdict::WeakParams);
505
+ }
506
+
507
+ #[test]
508
+ fn strong_bcrypt_cost_is_secure() {
509
+ let report = audit("$2b$12$abcdefghijklmnopqrstuv").unwrap();
510
+ assert_eq!(report.verdict, Verdict::Secure);
511
+ }
512
+
513
+ #[test]
514
+ fn raw_md5_is_broken() {
515
+ let report = audit("5f4dcc3b5aa765d61d8327deb882cf99").unwrap();
516
+ assert_eq!(report.verdict, Verdict::Broken);
517
+ }
518
+
519
+ #[test]
520
+ fn md5_crypt_is_deprecated() {
521
+ let report = audit("$1$salt$abcdefghijklmnopqrstuv").unwrap();
522
+ assert_eq!(report.verdict, Verdict::Deprecated);
523
+ }
524
+
525
+ use proptest::prelude::*;
526
+
527
+ proptest! {
528
+ #[test]
529
+ fn identify_never_panics(input in ".*") {
530
+ let _ = identify(&input);
531
+ }
532
+
533
+ #[test]
534
+ fn parse_phc_never_panics(input in ".*") {
535
+ let _ = parse_phc(&input);
536
+ }
537
+
538
+ #[test]
539
+ fn audit_never_panics(input in ".*") {
540
+ let _ = audit(&input);
541
+ }
542
+
543
+ #[test]
544
+ fn any_32_hex_is_md5_first(hex in "[0-9a-f]{32}") {
545
+ let result = identify(&hex);
546
+ prop_assert_eq!(result[0].algorithm.as_str(), "MD5");
547
+ }
548
+ }
549
+ }
@@ -0,0 +1,217 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.1"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
10
+
11
+ [[package]]
12
+ name = "cc"
13
+ version = "1.4.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
16
+ dependencies = [
17
+ "find-msvc-tools",
18
+ "shlex",
19
+ ]
20
+
21
+ [[package]]
22
+ name = "cfg-if"
23
+ version = "1.0.4"
24
+ source = "registry+https://github.com/rust-lang/crates.io-index"
25
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
26
+
27
+ [[package]]
28
+ name = "find-msvc-tools"
29
+ version = "0.1.11"
30
+ source = "registry+https://github.com/rust-lang/crates.io-index"
31
+ checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
32
+
33
+ [[package]]
34
+ name = "heck"
35
+ version = "0.5.0"
36
+ source = "registry+https://github.com/rust-lang/crates.io-index"
37
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
38
+
39
+ [[package]]
40
+ name = "indoc"
41
+ version = "2.0.7"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
44
+ dependencies = [
45
+ "rustversion",
46
+ ]
47
+
48
+ [[package]]
49
+ name = "libc"
50
+ version = "0.2.189"
51
+ source = "registry+https://github.com/rust-lang/crates.io-index"
52
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
53
+
54
+ [[package]]
55
+ name = "memoffset"
56
+ version = "0.9.1"
57
+ source = "registry+https://github.com/rust-lang/crates.io-index"
58
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
59
+ dependencies = [
60
+ "autocfg",
61
+ ]
62
+
63
+ [[package]]
64
+ name = "once_cell"
65
+ version = "1.21.4"
66
+ source = "registry+https://github.com/rust-lang/crates.io-index"
67
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
68
+
69
+ [[package]]
70
+ name = "portable-atomic"
71
+ version = "1.15.0"
72
+ source = "registry+https://github.com/rust-lang/crates.io-index"
73
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
74
+
75
+ [[package]]
76
+ name = "proc-macro2"
77
+ version = "1.0.107"
78
+ source = "registry+https://github.com/rust-lang/crates.io-index"
79
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
80
+ dependencies = [
81
+ "unicode-ident",
82
+ ]
83
+
84
+ [[package]]
85
+ name = "pyo3"
86
+ version = "0.23.5"
87
+ source = "registry+https://github.com/rust-lang/crates.io-index"
88
+ checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872"
89
+ dependencies = [
90
+ "cfg-if",
91
+ "indoc",
92
+ "libc",
93
+ "memoffset",
94
+ "once_cell",
95
+ "portable-atomic",
96
+ "pyo3-build-config",
97
+ "pyo3-ffi",
98
+ "pyo3-macros",
99
+ "unindent",
100
+ ]
101
+
102
+ [[package]]
103
+ name = "pyo3-build-config"
104
+ version = "0.23.5"
105
+ source = "registry+https://github.com/rust-lang/crates.io-index"
106
+ checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb"
107
+ dependencies = [
108
+ "once_cell",
109
+ "python3-dll-a",
110
+ "target-lexicon",
111
+ ]
112
+
113
+ [[package]]
114
+ name = "pyo3-ffi"
115
+ version = "0.23.5"
116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
117
+ checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d"
118
+ dependencies = [
119
+ "libc",
120
+ "pyo3-build-config",
121
+ ]
122
+
123
+ [[package]]
124
+ name = "pyo3-macros"
125
+ version = "0.23.5"
126
+ source = "registry+https://github.com/rust-lang/crates.io-index"
127
+ checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da"
128
+ dependencies = [
129
+ "proc-macro2",
130
+ "pyo3-macros-backend",
131
+ "quote",
132
+ "syn",
133
+ ]
134
+
135
+ [[package]]
136
+ name = "pyo3-macros-backend"
137
+ version = "0.23.5"
138
+ source = "registry+https://github.com/rust-lang/crates.io-index"
139
+ checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028"
140
+ dependencies = [
141
+ "heck",
142
+ "proc-macro2",
143
+ "pyo3-build-config",
144
+ "quote",
145
+ "syn",
146
+ ]
147
+
148
+ [[package]]
149
+ name = "python3-dll-a"
150
+ version = "0.2.15"
151
+ source = "registry+https://github.com/rust-lang/crates.io-index"
152
+ checksum = "d80ba7540edb18890d444c5aa8e1f1f99b1bdf26fb26ae383135325f4a36042b"
153
+ dependencies = [
154
+ "cc",
155
+ ]
156
+
157
+ [[package]]
158
+ name = "quote"
159
+ version = "1.0.47"
160
+ source = "registry+https://github.com/rust-lang/crates.io-index"
161
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
162
+ dependencies = [
163
+ "proc-macro2",
164
+ ]
165
+
166
+ [[package]]
167
+ name = "rustversion"
168
+ version = "1.0.23"
169
+ source = "registry+https://github.com/rust-lang/crates.io-index"
170
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
171
+
172
+ [[package]]
173
+ name = "saltminer-core"
174
+ version = "0.0.1"
175
+
176
+ [[package]]
177
+ name = "saltminer-py"
178
+ version = "0.1.0"
179
+ dependencies = [
180
+ "pyo3",
181
+ "saltminer-core",
182
+ ]
183
+
184
+ [[package]]
185
+ name = "shlex"
186
+ version = "2.0.1"
187
+ source = "registry+https://github.com/rust-lang/crates.io-index"
188
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
189
+
190
+ [[package]]
191
+ name = "syn"
192
+ version = "2.0.119"
193
+ source = "registry+https://github.com/rust-lang/crates.io-index"
194
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
195
+ dependencies = [
196
+ "proc-macro2",
197
+ "quote",
198
+ "unicode-ident",
199
+ ]
200
+
201
+ [[package]]
202
+ name = "target-lexicon"
203
+ version = "0.12.16"
204
+ source = "registry+https://github.com/rust-lang/crates.io-index"
205
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
206
+
207
+ [[package]]
208
+ name = "unicode-ident"
209
+ version = "1.0.24"
210
+ source = "registry+https://github.com/rust-lang/crates.io-index"
211
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
212
+
213
+ [[package]]
214
+ name = "unindent"
215
+ version = "0.2.4"
216
+ source = "registry+https://github.com/rust-lang/crates.io-index"
217
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
@@ -0,0 +1,15 @@
1
+ [package]
2
+ name = "saltminer-py"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ [lib]
8
+ name = "saltminer"
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ pyo3 = { version = "0.23", features = ["extension-module", "abi3-py310", "generate-import-lib"] }
13
+ saltminer-core = { path = "../saltminer-core" }
14
+
15
+ [workspace]
@@ -0,0 +1,27 @@
1
+ use pyo3::prelude::*;
2
+
3
+ #[pyfunction]
4
+ fn identify(input: &str) -> Vec<(String, String, String)> {
5
+ saltminer_core::identify(input)
6
+ .into_iter()
7
+ .map(|c| {
8
+ (
9
+ c.algorithm,
10
+ format!("{:?}", c.confidence).to_lowercase(),
11
+ c.reason,
12
+ )
13
+ })
14
+ .collect()
15
+ }
16
+
17
+ #[pyfunction]
18
+ fn audit(input: &str) -> Option<(String, String, String)> {
19
+ saltminer_core::audit(input).map(|r| (r.algorithm, format!("{:?}", r.verdict), r.detail))
20
+ }
21
+
22
+ #[pymodule]
23
+ fn saltminer(m: &Bound<'_, PyModule>) -> PyResult<()> {
24
+ m.add_function(wrap_pyfunction!(identify, m)?)?;
25
+ m.add_function(wrap_pyfunction!(audit, m)?)?;
26
+ Ok(())
27
+ }
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.13"
4
+
5
+ [[package]]
6
+ name = "saltminer"
7
+ version = "0.1.0"
8
+ source = { editable = "." }