Files
signer/src/selector.rs
T

224 lines
7.2 KiB
Rust

//! Selector resolution — matches a request's role selector against the role table.
//!
//! Port of `selector.c`.
use crate::role_table::RoleTable;
// ── Error Codes ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectorError {
Ok = 0,
Ambiguous = -1,
NotFound = -2,
NoDefault = -3,
PathMismatch = -4,
NostrIndexDeprecated = -5,
IndexDeprecated = -6,
RoleRequired = -7,
PathRequired = -8,
}
impl SelectorError {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Ambiguous => "ambiguous_role_selector",
Self::NotFound => "unknown_role",
Self::NoDefault => "no_default_role",
Self::PathMismatch => "path_not_allowed",
Self::NostrIndexDeprecated => "nostr_index_deprecated",
Self::IndexDeprecated => "index_deprecated",
Self::RoleRequired => "role_required",
Self::PathRequired => "path_required",
}
}
}
// ── Selector Request ─────────────────────────────────────────────────────────
/// Parsed selector from a request's options object.
#[derive(Debug, Clone, Default)]
pub struct SelectorRequest {
pub has_role: bool,
pub role_name: String,
pub has_nostr_index: bool,
pub nostr_index: i32,
pub has_role_path: bool,
pub role_path: String,
pub has_index: bool,
pub index: i32,
}
impl SelectorRequest {
pub fn new() -> Self {
Self::default()
}
}
// ── Resolution ───────────────────────────────────────────────────────────────
/// Resolve a selector request against the role table.
///
/// On success returns `Ok(role_index)` — the index into the table.
/// On failure returns the appropriate `SelectorError`.
pub fn selector_resolve(
req: &SelectorRequest,
table: &RoleTable,
) -> Result<usize, SelectorError> {
// Both role and role_path are required together (combined selector).
// No resolution order and no default role.
if !req.has_role && !req.has_nostr_index && !req.has_role_path {
// No selector given — check for default "main" role
if table.get_default().is_some() {
// Find the index of "main"
return Ok(table
.entries
.iter()
.position(|e| e.name == "main")
.ok_or(SelectorError::NoDefault)?);
}
return Err(SelectorError::NoDefault);
}
if req.has_role {
if !req.has_role_path {
// role without role_path — check if it's a fixed-path role
let entry = table.find_by_name(&req.role_name).ok_or(SelectorError::NotFound)?;
if entry.has_variable_path() {
// Variable path role needs role_path
return Err(SelectorError::PathRequired);
}
// Fixed path role — OK, return index
return Ok(table
.entries
.iter()
.position(|e| e.name == req.role_name)
.ok_or(SelectorError::NotFound)?);
}
// role + role_path: verify path matches the role's template
let entry = table.find_by_name(&req.role_name).ok_or(SelectorError::NotFound)?;
if !entry.path_matches_with_range(&req.role_path) {
return Err(SelectorError::PathMismatch);
}
return Ok(table
.entries
.iter()
.position(|e| e.name == req.role_name)
.ok_or(SelectorError::NotFound)?);
}
if req.has_nostr_index {
// nostr_index is deprecated — must use role + role_path
return Err(SelectorError::NostrIndexDeprecated);
}
if req.has_role_path && !req.has_role {
return Err(SelectorError::RoleRequired);
}
Err(SelectorError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::role_table::*;
fn make_test_table() -> RoleTable {
let mut table = RoleTable::new();
// main: fixed path
table.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
).unwrap();
// nostr_range: variable path with range 0-3
table.register_role_path(
"nostr_range",
"m/44'/1237'/%d'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
0, 3, -1, &[],
).unwrap();
table
}
#[test]
fn test_role_fixed_path() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "main".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/0'/0/0".to_string();
assert!(selector_resolve(&req, &table).is_ok());
}
#[test]
fn test_role_variable_path_in_range() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nostr_range".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/2'/0/0".to_string();
assert!(selector_resolve(&req, &table).is_ok());
}
#[test]
fn test_role_variable_path_out_of_range() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nostr_range".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/5'/0/0".to_string();
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::PathMismatch));
}
#[test]
fn test_role_without_path() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nostr_range".to_string();
// Variable path role without role_path → PathRequired
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::PathRequired));
}
#[test]
fn test_path_without_role() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role_path = true;
req.role_path = "m/44'/1237'/0'/0/0".to_string();
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::RoleRequired));
}
#[test]
fn test_unknown_role() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nonexistent".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/0'/0/0".to_string();
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::NotFound));
}
#[test]
fn test_no_selector_with_default() {
let table = make_test_table();
let req = SelectorRequest::new();
// No selector — should find "main" as default
assert!(selector_resolve(&req, &table).is_ok());
}
}