Skip to content

Commit 331efb4

Browse files
committed
Remove redundant 0.7.0 changelog bullet
1 parent 568bf6e commit 331efb4

File tree

9 files changed

+205
-44
lines changed

9 files changed

+205
-44
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/site
22
/target
33
/.shadowenv.d
4+
.venv/
45
*.so
56
__pycache__/

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
Released XXXX-XX-XX
44

5+
## 0.7.0
6+
7+
Released 2025-10-19
8+
59
* [#3](https://github.com/tiwilliam/rsmime/pull/3) - Replace black with ruff for formatting.
10+
* [#4](https://github.com/tiwilliam/rsmime/pull/4) - Introduce ``cert_data`` and ``key_data`` options for supplying certificate material without temporary files.
611

712
## 0.6.4
813

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rsmime"
3-
version = "0.6.4"
3+
version = "0.7.0"
44
edition = "2021"
55

66
[lib]

docs/getting_started.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ except (SignError, CertificateError) as e:
2323
print("Failed to sign:", e)
2424
```
2525

26+
If you already have the PEM data in memory you can also provide it directly as text or bytes:
27+
28+
```python
29+
from pathlib import Path
30+
from rsmime import Rsmime
31+
32+
certificate = Path("some.crt").read_text()
33+
private_key = Path("some.key").read_text()
34+
35+
client = Rsmime(cert_data=certificate, key_data=private_key)
36+
37+
certificate_bytes = Path("some.crt").read_bytes()
38+
private_key_bytes = Path("some.key").read_bytes()
39+
40+
client = Rsmime(cert_data=certificate_bytes, key_data=private_key_bytes)
41+
```
42+
2643
### Output
2744

2845
```bash

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "rsmime"
7-
version = "0.6.4"
7+
version = "0.7.0"
88
description = "Python package for signing and verifying S/MIME messages"
99
classifiers = [
1010
"License :: OSI Approved :: MIT License",

python/rsmime/__init__.pyi

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1+
from os import PathLike
2+
13
class Rsmime:
2-
def __init__(self, cert_file: str, key_file: str) -> None:
3-
"""Initialize client and load certificate from disk.
4+
def __init__(
5+
self,
6+
cert_file: str | PathLike[str] | None = ...,
7+
key_file: str | PathLike[str] | None = ...,
8+
*,
9+
cert_data: str | bytes | None = ...,
10+
key_data: str | bytes | None = ...,
11+
) -> None:
12+
"""Initialize client and load certificate material.
413
514
Parameters:
6-
cert_file: Path to certificate on disk.
7-
key_file: Path to private key on disk.
15+
cert_file: Path to certificate on disk. Mutually exclusive with ``cert_data``.
16+
key_file: Path to private key on disk. Mutually exclusive with ``key_data``.
17+
cert_data: PEM-encoded certificate contents provided as a string or bytes.
18+
key_data: PEM-encoded private key contents provided as a string or bytes.
819
920
Raises:
10-
exceptions.CertificateError: If there is an error reading the certificate or key.
21+
exceptions.CertificateError: If there is an error loading, parsing, or when
22+
both a file path and in-memory value are provided for the same artifact.
1123
"""
1224
...
1325
def sign(self, message: bytes, *, detached: bool = False) -> bytes:

src/lib.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
extern crate openssl;
22

33
use std::io::{Error, ErrorKind};
4+
use std::path::PathBuf;
45

56
use openssl::nid::Nid;
67
use openssl::pkcs7::{Pkcs7, Pkcs7Flags};
@@ -95,6 +96,39 @@ create_exception!(exceptions, CertificateExpiredError, CertificateError);
9596
create_exception!(exceptions, SignError, RsmimeError);
9697
create_exception!(exceptions, VerifyError, RsmimeError);
9798

99+
fn material_from_sources(
100+
py: Python<'_>,
101+
file: Option<PathBuf>,
102+
data: Option<Py<PyAny>>,
103+
file_label: &str,
104+
data_label: &str,
105+
) -> PyResult<Vec<u8>> {
106+
match (file, data) {
107+
(Some(path), None) => {
108+
std::fs::read(path).map_err(|err| CertificateError::new_err(err.to_string()))
109+
}
110+
(None, Some(obj)) => {
111+
let obj = obj.as_ref(py);
112+
113+
if let Ok(value) = obj.extract::<&str>() {
114+
Ok(value.as_bytes().to_vec())
115+
} else if let Ok(value) = obj.extract::<&[u8]>() {
116+
Ok(value.to_vec())
117+
} else {
118+
Err(CertificateError::new_err(format!(
119+
"{data_label} must be a str or bytes-like object"
120+
)))
121+
}
122+
}
123+
(Some(_), Some(_)) => Err(CertificateError::new_err(format!(
124+
"Provide either {file_label} or {data_label}, not both"
125+
))),
126+
(None, None) => Err(CertificateError::new_err(format!(
127+
"A value must be provided via {file_label} or {data_label}"
128+
))),
129+
}
130+
}
131+
98132
#[pymodule]
99133
fn exceptions(py: Python, m: &PyModule) -> PyResult<()> {
100134
m.add("RsmimeError", py.get_type::<RsmimeError>())?;
@@ -132,20 +166,23 @@ struct Rsmime {
132166
#[pymethods]
133167
impl Rsmime {
134168
#[new]
135-
#[pyo3(signature = (cert_file, key_file))]
136-
fn new(cert_file: String, key_file: String) -> PyResult<Self> {
169+
#[pyo3(signature = (cert_file=None, key_file=None, *, cert_data=None, key_data=None))]
170+
fn new(
171+
py: Python<'_>,
172+
cert_file: Option<PathBuf>,
173+
key_file: Option<PathBuf>,
174+
cert_data: Option<Py<PyAny>>,
175+
key_data: Option<Py<PyAny>>,
176+
) -> PyResult<Self> {
137177
let stack = Stack::new().unwrap();
138178

139-
let cert_data =
140-
std::fs::read(cert_file).map_err(|err| CertificateError::new_err(err.to_string()))?;
179+
let cert_bytes = material_from_sources(py, cert_file, cert_data, "cert_file", "cert_data")?;
180+
let key_bytes = material_from_sources(py, key_file, key_data, "key_file", "key_data")?;
141181

142-
let cert =
143-
X509::from_pem(&cert_data).map_err(|err| CertificateError::new_err(err.to_string()))?;
144-
145-
let key_data =
146-
std::fs::read(key_file).map_err(|err| CertificateError::new_err(err.to_string()))?;
182+
let cert = X509::from_pem(&cert_bytes)
183+
.map_err(|err| CertificateError::new_err(err.to_string()))?;
147184

148-
let rsa = Rsa::private_key_from_pem(&key_data)
185+
let rsa = Rsa::private_key_from_pem(&key_bytes)
149186
.map_err(|err| CertificateError::new_err(err.to_string()))?;
150187
let pkey = PKey::from_rsa(rsa).map_err(|err| CertificateError::new_err(err.to_string()))?;
151188

tests/test_rsmime.py

Lines changed: 115 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,88 @@
1+
from pathlib import Path
2+
13
import pytest
24
from callee import strings
5+
36
from rsmime import Rsmime, exceptions
47

8+
9+
ATTACHED_SIGNATURE_REGEX = strings.Regex(
10+
b'MIME-Version: 1.0\n'
11+
b'Content-Disposition: attachment; filename="smime.p7m"\n'
12+
b'Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"\n'
13+
b'Content-Transfer-Encoding: base64\n'
14+
b'\n'
15+
b'MIIJwQYJKoZIhvcNAQcCoIIJsjCCCa4CAQExDzANBglghkgBZQMEAgEFADASBgkq\n'
16+
b'[A-Za-z0-9/+=\n]+\n'
17+
b'\n'
18+
)
19+
20+
DETACHED_SIGNATURE_REGEX = strings.Regex(
21+
b'MIME-Version: 1.0\n'
22+
b'Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----[A-Z0-9]+"\n\n'
23+
b'This is an S/MIME signed message\n\n'
24+
b'------[A-Z0-9]+\n'
25+
b'abc\n'
26+
b'------[A-Z0-9]+\n'
27+
b'Content-Type: application/x-pkcs7-signature; name="smime.p7s"\n'
28+
b'Content-Transfer-Encoding: base64\n'
29+
b'Content-Disposition: attachment; filename="smime.p7s"\n'
30+
b'\n'
31+
b'MIIJugYJKoZIhvcNAQcCoIIJqzCCCacCAQExDzANBglghkgBZQMEAgEFADALBgkq\n'
32+
b'[A-Za-z0-9/+=\n]+\n'
33+
b'\n'
34+
b'------[A-Z0-9]+--\n'
35+
b'\n'
36+
)
37+
38+
39+
def _load_text(path: str) -> str:
40+
return Path(path).read_text()
41+
42+
543
working_client = Rsmime('tests/data/certificate.crt', 'tests/data/certificate.key')
644
expired_client = Rsmime('tests/data/expired.crt', 'tests/data/certificate.key')
745

846

947
class TestRsmime:
1048
def test_sign(self):
1149
signed_data = working_client.sign(b'abc')
12-
assert signed_data == strings.Regex(
13-
b'MIME-Version: 1.0\n'
14-
b'Content-Disposition: attachment; filename="smime.p7m"\n'
15-
b'Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"\n'
16-
b'Content-Transfer-Encoding: base64\n'
17-
b'\n'
18-
b'MIIJwQYJKoZIhvcNAQcCoIIJsjCCCa4CAQExDzANBglghkgBZQMEAgEFADASBgkq\n'
19-
b'[A-Za-z0-9/+=\n]+\n'
20-
b'\n'
21-
)
50+
assert signed_data == ATTACHED_SIGNATURE_REGEX
2251

2352
def test_sign_detached(self):
2453
signed_data = working_client.sign(b'abc', detached=True)
25-
assert signed_data == strings.Regex(
26-
b'MIME-Version: 1.0\n'
27-
b'Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----[A-Z0-9]+"\n\n'
28-
b'This is an S/MIME signed message\n\n'
29-
b'------[A-Z0-9]+\n'
30-
b'abc\n'
31-
b'------[A-Z0-9]+\n'
32-
b'Content-Type: application/x-pkcs7-signature; name="smime.p7s"\n'
33-
b'Content-Transfer-Encoding: base64\n'
34-
b'Content-Disposition: attachment; filename="smime.p7s"\n'
35-
b'\n'
36-
b'MIIJugYJKoZIhvcNAQcCoIIJqzCCCacCAQExDzANBglghkgBZQMEAgEFADALBgkq\n'
37-
b'[A-Za-z0-9/+=\n]+\n'
38-
b'\n'
39-
b'------[A-Z0-9]+--\n'
40-
b'\n'
54+
assert signed_data == DETACHED_SIGNATURE_REGEX
55+
56+
def test_sign_with_in_memory_material(self):
57+
client = Rsmime(
58+
cert_data=_load_text('tests/data/certificate.crt'),
59+
key_data=_load_text('tests/data/certificate.key'),
60+
)
61+
62+
signed_data = client.sign(b'abc')
63+
64+
assert signed_data == ATTACHED_SIGNATURE_REGEX
65+
66+
def test_sign_with_in_memory_bytes_material(self):
67+
client = Rsmime(
68+
cert_data=_load_text('tests/data/certificate.crt').encode(),
69+
key_data=_load_text('tests/data/certificate.key').encode(),
70+
)
71+
72+
signed_data = client.sign(b'abc')
73+
74+
assert signed_data == ATTACHED_SIGNATURE_REGEX
75+
76+
def test_sign_with_path_objects(self):
77+
client = Rsmime(
78+
Path('tests/data/certificate.crt'),
79+
Path('tests/data/certificate.key'),
4180
)
4281

82+
signed_data = client.sign(b'abc')
83+
84+
assert signed_data == ATTACHED_SIGNATURE_REGEX
85+
4386
def test_sign_missing_cert(self):
4487
with pytest.raises(
4588
exceptions.CertificateError, match='No such file or directory'
@@ -64,6 +107,52 @@ def test_sign_empty_data(self):
64107
with pytest.raises(exceptions.SignError, match='Cannot sign empty data'):
65108
working_client.sign(b'')
66109

110+
def test_conflicting_certificate_inputs(self):
111+
with pytest.raises(
112+
exceptions.CertificateError,
113+
match='Provide either cert_file or cert_data',
114+
):
115+
Rsmime(
116+
'tests/data/certificate.crt',
117+
'tests/data/certificate.key',
118+
cert_data=_load_text('tests/data/certificate.crt'),
119+
)
120+
121+
def test_conflicting_key_inputs(self):
122+
with pytest.raises(
123+
exceptions.CertificateError,
124+
match='Provide either key_file or key_data',
125+
):
126+
Rsmime(
127+
'tests/data/certificate.crt',
128+
'tests/data/certificate.key',
129+
key_data=_load_text('tests/data/certificate.key'),
130+
)
131+
132+
def test_missing_certificate_input(self):
133+
with pytest.raises(
134+
exceptions.CertificateError,
135+
match='cert_file or cert_data',
136+
):
137+
Rsmime(key_data=_load_text('tests/data/certificate.key'))
138+
139+
def test_missing_key_input(self):
140+
with pytest.raises(
141+
exceptions.CertificateError,
142+
match='key_file or key_data',
143+
):
144+
Rsmime(cert_data=_load_text('tests/data/certificate.crt'))
145+
146+
def test_in_memory_material_requires_text_or_bytes(self):
147+
with pytest.raises(
148+
exceptions.CertificateError,
149+
match='cert_data must be a str or bytes-like object',
150+
):
151+
Rsmime(
152+
cert_data=object(),
153+
key_data=_load_text('tests/data/certificate.key'),
154+
)
155+
67156
def test_verify(self):
68157
data = b'abc'
69158
signed_data = working_client.sign(data)

0 commit comments

Comments
 (0)