diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..0bccbcb5 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,115 @@ +name: Python Bindings + +on: + push: + branches: [main] + paths: + - 'python/**' + - 'client/**' + - '.github/workflows/python.yml' + pull_request: + branches: [main] + paths: + - 'python/**' + - 'client/**' + - '.github/workflows/python.yml' + release: + types: [published] + +jobs: + test: + name: Test Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y gnome-keyring dbus-x11 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Python dependencies + working-directory: python + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Build and install Python package + working-directory: python + run: | + maturin build --release + pip install ../target/wheels/oo7_python-*.whl + + - name: Start gnome-keyring + run: | + dbus-run-session -- sh -c ' + echo "test" | gnome-keyring-daemon --unlock + gnome-keyring-daemon --start --daemonize --components=secrets + export $(gnome-keyring-daemon --start) + cd python && pytest -v + ' + + lint: + name: Lint Python bindings + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install ruff + run: pip install ruff + + - name: Check Python formatting + working-directory: python + run: ruff format --check tests/ + + - name: Check Python linting + working-directory: python + run: ruff check tests/ + + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + if: github.event_name == 'release' + needs: [test, lint] + permissions: + id-token: write + + steps: + - uses: actions/checkout@v6 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: python + command: build + args: --release --out dist --interpreter 3.8 3.9 3.10 3.11 3.12 3.13 + + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + with: + working-directory: python + command: upload + args: --non-interactive --skip-existing dist/* + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index f0b2f85e..67536504 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ #Cargo.lock cli/target coverage/ +python/.venv +python/*.so +python/**/__pycache__ diff --git a/Cargo.lock b/Cargo.lock index 4ba115ec..6bc032c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -928,6 +928,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1355,6 +1364,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "oo7-python" +version = "0.6.0" +dependencies = [ + "oo7", + "pyo3", + "pyo3-async-runtimes", + "tokio", +] + [[package]] name = "openssl" version = "0.10.75" @@ -1491,6 +1510,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "potential_utf" version = "0.1.3" @@ -1533,6 +1558,82 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b83dc42f9d41f50d38180dad65f0c99763b65a3ff2a81bf351dd35a1df8bf" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.41" @@ -1900,6 +2001,12 @@ dependencies = [ "syn", ] +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + [[package]] name = "temp-dir" version = "0.1.16" @@ -2156,6 +2263,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "url" version = "2.5.7" diff --git a/Cargo.toml b/Cargo.toml index 288d6653..475976e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "cli", "pam", "portal", + "python", "server", ] diff --git a/coverage.sh b/coverage.sh index bb5deb00..655b934d 100755 --- a/coverage.sh +++ b/coverage.sh @@ -63,6 +63,7 @@ grcov coverage-raw/combined.info \ --branch \ --ignore-not-existing \ --ignore "**/portal/*" \ + --ignore "**/python/*" \ --ignore "**/cli/*" \ --ignore "**/pam/*" \ --ignore "**/tests/*" \ @@ -79,6 +80,7 @@ grcov coverage-raw/combined.info \ --branch \ --ignore-not-existing \ --ignore "**/portal/*" \ + --ignore "**/python/*" \ --ignore "**/cli/*" \ --ignore "**/pam/*" \ --ignore "**/tests/*" \ diff --git a/deny.toml b/deny.toml index a515bb31..4247a7c3 100644 --- a/deny.toml +++ b/deny.toml @@ -15,6 +15,7 @@ allow = [ "Apache-2.0", # rpassword by cli only "BSD-3-Clause", # used by subtle -> digest "Unicode-3.0", # used by icu_collections -> url + "Apache-2.0 WITH LLVM-exception", # target-lexicon -> pyo3 ] [sources] diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 00000000..821037f0 --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "oo7-python" +version.workspace = true +edition.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Python bindings for oo7" + +[lib] +name = "oo7" +crate-type = ["cdylib"] + +[dependencies] +oo7_rs = { package = "oo7", path = "../client", version = "0.6" } +pyo3 = { version = "0.24", features = ["extension-module", "abi3-py38"] } +pyo3-async-runtimes = { version = "0.24", features = ["tokio-runtime"] } +tokio = { version = "1", features = ["rt-multi-thread"] } + +[profile.release] +lto = true +codegen-units = 1 diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..338ce67f --- /dev/null +++ b/python/README.md @@ -0,0 +1,57 @@ +# oo7 Python Bindings + +Python bindings for [oo7](../client/), providing access to Secret Service API on Linux. Automatically uses a file-based keyring when running in a sandboxed environment. + +## Installation + +```bash +cd python +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +maturin develop +``` + +## Usage + +```python +import asyncio +import oo7 + +async def main(): + # Create keyring + keyring = await oo7.Keyring.new() + + # Store a secret + await keyring.create_item( + "My Password", + {"application": "myapp", "username": "alice"}, + b"secret-password", + replace=True + ) + + # Search for items + items = await keyring.search_items({"application": "myapp"}) + for item in items: + secret = await item.secret() + print(f"Secret: {secret}") + + # Clean up + await keyring.delete({"application": "myapp"}) + +asyncio.run(main()) +``` + +## Running Tests + +```bash +pytest +``` + +## Examples + +See `tests/test_keyring.py` for more examples. + +## License + +MIT diff --git a/python/oo7.pyi b/python/oo7.pyi new file mode 100644 index 00000000..fb419309 --- /dev/null +++ b/python/oo7.pyi @@ -0,0 +1,297 @@ +""" +Type stubs for oo7 + +This module provides async-only Python bindings to the oo7 Rust crate, +which implements the Secret Service API on Linux. Automatically uses a +file-based keyring when running in a sandboxed environment. +""" + +from typing import Dict, List, Union + +class Keyring: + """ + A Secret Service or file-backed keyring implementation. + + The keyring automatically selects between file-based (for sandboxed apps) + and DBus-based backends. + """ + + @staticmethod + async def new() -> Keyring: + """ + Create a new Keyring instance. + + Returns: + Keyring: A new keyring instance + + Raises: + RuntimeError: If keyring initialization fails + """ + ... + + async def unlock(self) -> None: + """ + Unlock the keyring. + + Returns: + None + + Raises: + RuntimeError: If unlock operation fails + """ + ... + + async def lock(self) -> None: + """ + Lock the keyring. + + Returns: + None + + Raises: + RuntimeError: If lock operation fails + """ + ... + + async def is_locked(self) -> bool: + """ + Check if the keyring is locked. + + Returns: + bool: True if locked, False otherwise + + Raises: + RuntimeError: If the check fails + """ + ... + + async def delete(self, attributes: Dict[str, str]) -> None: + """ + Delete items matching the given attributes. + + Args: + attributes: Dictionary of attribute key-value pairs to match + + Returns: + None + + Raises: + RuntimeError: If deletion fails + """ + ... + + async def items(self) -> List[Item]: + """ + Retrieve all items in the keyring. + + Returns: + list[Item]: List of all items + + Raises: + RuntimeError: If retrieval fails + """ + ... + + async def create_item( + self, + label: str, + attributes: Dict[str, str], + secret: Union[bytes, str], + replace: bool + ) -> None: + """ + Create a new item in the keyring. + + Args: + label: Human-readable label for the item + attributes: Dictionary of attribute key-value pairs + secret: The secret to store (bytes or str) + replace: If True, replace existing items with matching attributes + + Returns: + None + + Raises: + RuntimeError: If item creation fails + ValueError: If secret is not bytes or str + """ + ... + + async def search_items(self, attributes: Dict[str, str]) -> List[Item]: + """ + Search for items matching the given attributes. + + Args: + attributes: Dictionary of attribute key-value pairs to match + + Returns: + list[Item]: List of matching items + + Raises: + RuntimeError: If search fails + """ + ... + + +class Item: + """ + A secret item in the keyring. + + Items have a label, attributes for searching, and a secret value. + They can be locked/unlocked individually (though some backends may + lock/unlock the entire collection). + """ + + async def label(self) -> str: + """ + Get the item's label. + + Returns: + str: The item label + + Raises: + RuntimeError: If retrieval fails or item is locked + """ + ... + + async def set_label(self, label: str) -> None: + """ + Set the item's label. + + Args: + label: New label for the item + + Returns: + None + + Raises: + RuntimeError: If setting fails or item is locked + """ + ... + + async def attributes(self) -> Dict[str, str]: + """ + Get the item's attributes. + + Returns: + dict: Dictionary of attribute key-value pairs + + Raises: + RuntimeError: If retrieval fails or item is locked + """ + ... + + async def set_attributes(self, attributes: Dict[str, str]) -> None: + """ + Set the item's attributes. + + Args: + attributes: New attributes for the item + + Returns: + None + + Raises: + RuntimeError: If setting fails or item is locked + """ + ... + + async def secret(self) -> bytes: + """ + Get the item's secret. + + Returns: + bytes: The secret value as bytes + + Raises: + RuntimeError: If retrieval fails or item is locked + """ + ... + + async def set_secret(self, secret: Union[bytes, str]) -> None: + """ + Set the item's secret. + + Args: + secret: New secret value (bytes or str) + + Returns: + None + + Raises: + RuntimeError: If setting fails or item is locked + ValueError: If secret is not bytes or str + """ + ... + + async def is_locked(self) -> bool: + """ + Check if the item is locked. + + Returns: + bool: True if locked, False otherwise + + Raises: + RuntimeError: If the check fails + """ + ... + + async def lock(self) -> None: + """ + Lock the item. + + Returns: + None + + Raises: + RuntimeError: If lock operation fails + """ + ... + + async def unlock(self) -> None: + """ + Unlock the item. + + Returns: + None + + Raises: + RuntimeError: If unlock operation fails + """ + ... + + async def delete(self) -> None: + """ + Delete the item. + + Returns: + None + + Raises: + RuntimeError: If deletion fails + """ + ... + + async def created(self) -> float: + """ + Get the UNIX timestamp when the item was created. + + Returns: + float: Seconds since UNIX epoch + + Raises: + RuntimeError: If retrieval fails or item is locked + """ + ... + + async def modified(self) -> float: + """ + Get the UNIX timestamp when the item was last modified. + + Returns: + float: Seconds since UNIX epoch + + Raises: + RuntimeError: If retrieval fails or item is locked + """ + ... diff --git a/python/py.typed b/python/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..78872734 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "oo7-python" +version = "0.6.0" +requires-python = ">=3.8" +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Rust", + "Topic :: Security", + "Topic :: Security :: Cryptography", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Systems Administration", +] + +[tool.maturin] +include = ["oo7.pyi", "py.typed"] + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] +ignore = [] diff --git a/python/pytest.ini b/python/pytest.ini new file mode 100644 index 00000000..6f94355f --- /dev/null +++ b/python/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt new file mode 100644 index 00000000..1f832fba --- /dev/null +++ b/python/requirements-dev.txt @@ -0,0 +1,4 @@ +maturin>=1.0,<2.0 +pytest>=7.0 +pytest-asyncio>=0.21 +ruff>=0.1.0 diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 00000000..6465d0b4 --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,272 @@ +#![allow(unsafe_op_in_unsafe_fn)] + +use std::{collections::HashMap, sync::Arc}; + +use pyo3::{ + exceptions::{PyRuntimeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use pyo3_async_runtimes::tokio::future_into_py; + +fn convert_error(err: oo7_rs::Error) -> PyErr { + PyRuntimeError::new_err(format!("oo7 error: {:?}", err)) +} + +#[derive(Clone)] +struct SecretBytes(Vec); + +impl<'py> IntoPyObject<'py> for SecretBytes { + type Target = PyBytes; + type Output = Bound<'py, Self::Target>; + type Error = std::convert::Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + Ok(PyBytes::new(py, &self.0)) + } +} + +fn python_to_secret(obj: &Bound) -> PyResult { + if let Ok(bytes) = obj.extract::>() { + Ok(oo7_rs::Secret::from(bytes)) + } else if let Ok(text) = obj.extract::() { + Ok(oo7_rs::Secret::text(&text)) + } else { + Err(PyValueError::new_err("Secret must be either bytes or str")) + } +} + +#[pyclass] +struct Keyring { + inner: Arc, +} + +#[pymethods] +impl Keyring { + #[staticmethod] + #[pyo3(name = "new")] + fn create(py: Python<'_>) -> PyResult> { + future_into_py(py, async move { + let keyring = oo7_rs::Keyring::new().await.map_err(convert_error)?; + Ok(Keyring { + inner: Arc::new(keyring), + }) + }) + } + + fn unlock<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.unlock().await.map_err(convert_error)?; + Ok(()) + }) + } + + fn lock<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.lock().await.map_err(convert_error)?; + Ok(()) + }) + } + + fn is_locked<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let locked = inner.is_locked().await.map_err(convert_error)?; + Ok(locked) + }) + } + + fn delete<'p>( + &self, + py: Python<'p>, + attributes: HashMap, + ) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.delete(&attributes).await.map_err(convert_error)?; + Ok(()) + }) + } + + fn items<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let items = inner.items().await.map_err(convert_error)?; + let py_items: Vec = items + .into_iter() + .map(|item| Item { + inner: Arc::new(item), + }) + .collect(); + Ok(py_items) + }) + } + + fn create_item<'p>( + &self, + py: Python<'p>, + label: String, + attributes: HashMap, + secret: &Bound<'p, PyAny>, + replace: bool, + ) -> PyResult> { + let inner = Arc::clone(&self.inner); + let secret = python_to_secret(secret)?; + + future_into_py(py, async move { + inner + .create_item(&label, &attributes, secret, replace) + .await + .map_err(convert_error)?; + Ok(()) + }) + } + + fn search_items<'p>( + &self, + py: Python<'p>, + attributes: HashMap, + ) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let items = inner + .search_items(&attributes) + .await + .map_err(convert_error)?; + let py_items: Vec = items + .into_iter() + .map(|item| Item { + inner: Arc::new(item), + }) + .collect(); + Ok(py_items) + }) + } +} + +#[pyclass] +struct Item { + inner: Arc, +} + +#[pymethods] +impl Item { + fn label<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let label = inner.label().await.map_err(convert_error)?; + Ok(label) + }) + } + + fn set_label<'p>(&self, py: Python<'p>, label: String) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.set_label(&label).await.map_err(convert_error)?; + Ok(()) + }) + } + + fn attributes<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let attrs = inner.attributes().await.map_err(convert_error)?; + Ok(attrs) + }) + } + + fn set_attributes<'p>( + &self, + py: Python<'p>, + attributes: HashMap, + ) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner + .set_attributes(&attributes) + .await + .map_err(convert_error)?; + Ok(()) + }) + } + + fn secret<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let secret = inner.secret().await.map_err(convert_error)?; + let bytes = SecretBytes(secret.as_bytes().to_vec()); + Ok(bytes) + }) + } + + fn set_secret<'p>( + &self, + py: Python<'p>, + secret: &Bound<'p, PyAny>, + ) -> PyResult> { + let inner = Arc::clone(&self.inner); + let secret = python_to_secret(secret)?; + + future_into_py(py, async move { + inner.set_secret(secret).await.map_err(convert_error)?; + Ok(()) + }) + } + + fn is_locked<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let locked = inner.is_locked().await.map_err(convert_error)?; + Ok(locked) + }) + } + + fn lock<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.lock().await.map_err(convert_error)?; + Ok(()) + }) + } + + fn unlock<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.unlock().await.map_err(convert_error)?; + Ok(()) + }) + } + + fn delete<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.delete().await.map_err(convert_error)?; + Ok(()) + }) + } + + fn created<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let duration = inner.created().await.map_err(convert_error)?; + Ok(duration.as_secs_f64()) + }) + } + + fn modified<'p>(&self, py: Python<'p>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + let duration = inner.modified().await.map_err(convert_error)?; + Ok(duration.as_secs_f64()) + }) + } +} + +#[pymodule] +fn oo7(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/tests/test_keyring.py b/python/tests/test_keyring.py new file mode 100644 index 00000000..97d2692d --- /dev/null +++ b/python/tests/test_keyring.py @@ -0,0 +1,158 @@ +""" +Tests for oo7 Python bindings +""" + +import pytest + +import oo7 + + +@pytest.mark.asyncio +async def test_basic_operations(): + keyring = await oo7.Keyring.new() + + is_locked = await keyring.is_locked() + assert isinstance(is_locked, bool) + + await keyring.create_item( + "Test Password", + { + "application": "oo7-python-test", + "username": "alice", + "service": "example.com", + }, + b"my-secret-password", + replace=True, + ) + + items = await keyring.search_items({"application": "oo7-python-test"}) + assert len(items) == 1 + + item = items[0] + label = await item.label() + assert label == "Test Password" + + attrs = await item.attributes() + assert attrs["application"] == "oo7-python-test" + assert attrs["username"] == "alice" + assert attrs["service"] == "example.com" + + secret = await item.secret() + assert secret == b"my-secret-password" + + created = await item.created() + assert created > 0 + + modified = await item.modified() + assert modified > 0 + assert modified >= created + + is_locked = await item.is_locked() + assert isinstance(is_locked, bool) + + await keyring.delete({"application": "oo7-python-test"}) + + +@pytest.mark.asyncio +async def test_item_mutations(): + keyring = await oo7.Keyring.new() + + await keyring.create_item( + "Original Label", + {"application": "oo7-mutation-test", "version": "1.0"}, + "original-secret", + replace=True, + ) + + items = await keyring.search_items({"application": "oo7-mutation-test"}) + item = items[0] + + await item.set_label("Updated Label") + new_label = await item.label() + assert new_label == "Updated Label" + + await item.set_attributes( + { + "application": "oo7-mutation-test", + "version": "2.0", + "new-field": "new-value", + } + ) + new_attrs = await item.attributes() + assert new_attrs["version"] == "2.0" + assert new_attrs["new-field"] == "new-value" + + await item.set_secret(b"new-secret-value") + new_secret = await item.secret() + assert new_secret == b"new-secret-value" + + await item.delete() + + items = await keyring.search_items({"application": "oo7-mutation-test"}) + assert len(items) == 0 + + +@pytest.mark.asyncio +async def test_multiple_items(): + keyring = await oo7.Keyring.new() + + for i in range(3): + await keyring.create_item( + f"Test Item {i}", + { + "application": "oo7-multi-test", + "index": str(i), + "group": "test-group", + }, + f"secret-{i}".encode(), + replace=True, + ) + + items = await keyring.search_items({"application": "oo7-multi-test"}) + assert len(items) == 3 + + # Search for specific item + specific = await keyring.search_items( + {"application": "oo7-multi-test", "index": "1"} + ) + assert len(specific) == 1 + label = await specific[0].label() + assert label == "Test Item 1" + secret = await specific[0].secret() + assert secret == b"secret-1" + + await keyring.delete({"application": "oo7-multi-test"}) + + +@pytest.mark.asyncio +async def test_string_and_bytes_secrets(): + """Test that both string and bytes secrets work""" + keyring = await oo7.Keyring.new() + + await keyring.create_item( + "String Secret", + {"application": "oo7-type-test", "type": "string"}, + "text-based-secret", + replace=True, + ) + + await keyring.create_item( + "Bytes Secret", + {"application": "oo7-type-test", "type": "bytes"}, + b"\x00\x01\x02\x03\x04", + replace=True, + ) + + items = await keyring.search_items({"application": "oo7-type-test"}) + assert len(items) == 2 + + for item in items: + attrs = await item.attributes() + secret = await item.secret() + + if attrs["type"] == "string": + assert secret == b"text-based-secret" + elif attrs["type"] == "bytes": + assert secret == b"\x00\x01\x02\x03\x04" + + await keyring.delete({"application": "oo7-type-test"})