UnicodeSecurity (UnicodeSecurity v0.1.0)

Copy Markdown View Source

Unicode identifier security primitives backed by pinned Unicode data.

Provides skeleton/1, script, number, and restriction-level detection, identifier properties, check/2 policy Results/Reasons for identifiers and domains, and compiled-data metadata. A skeleton is a comparison key only. It must never serve as a canonical identifier, replacement value, or authorization decision. Matching keys do not establish identity or intent.

Unicode 18.0.0 data is final. See data_manifest/0 for pinned source hashes and final status. Runtime calls use only compiled data, with no file or network access or application processes.

Summary

Functions

Returns whether a string belongs to the canonically closed UTS #39 General Security Profile.

Lazily audits an enumerable of candidate identifiers under one policy.

Analyzes an identifier and returns original-input facts and policy findings.

Eagerly audits an enumerable and returns ordered exact-duplicate and shared-skeleton groups alongside unchanged item results.

Returns comparison facts and original-input mapping evidence for two identifiers.

Returns the pinned skeleton key for a required identifier type.

Returns all skeleton matches in enumerable order with original indexes and evidence.

Returns whether any visited existing identifier has the candidate's skeleton.

Returns whether two original inputs share a skeleton but differ canonically.

Returns the provenance of the compiled Unicode data.

Returns the exact pinned UTS #39 Identifier_Status value for a Unicode scalar.

Returns the sorted exact pinned UTS #39 Identifier_Type set for a Unicode scalar.

Detects multiple decimal number systems using UTS #39 revision 34, section 5.3.

Detects mixed scripts using UTS #39 revision 34, section 5.1.

Returns the first applicable UTS #39 revision 34, section 5.2 restriction level.

Returns whether two validated original inputs have equal pinned skeleton keys.

Returns the sorted unique Script property values observed in the original input.

Returns the UTS #39 confusable skeleton using the pinned Unicode data.

Returns the Unicode version used by the compiled final data; see data_manifest/0 for provenance.

Returns the UTS #39 revision targeted by this milestone's skeleton implementation.

Types

restriction_level()

@type restriction_level() ::
  :ascii
  | :single_script_restrictive
  | :highly_restrictive
  | :moderately_restrictive
  | :minimally_restrictive
  | :unrestricted

Functions

allowed_identifier?(input)

@spec allowed_identifier?(binary()) :: boolean()

Returns whether a string belongs to the canonically closed UTS #39 General Security Profile.

Membership permits the input when some canonically equivalent representation consists entirely of characters whose Identifier_Status is Allowed. It does not add application syntax exceptions, validate identifier grammar, or make a safety or authorization verdict. Empty input is vacuously allowed.

Accepts a UTF-8 binary of at most 4,096 bytes. Raises ArgumentError for nonbinary input and UnicodeSecurity.InvalidInputError for malformed UTF-8 or oversized input, with offsets in the original input.

Examples

iex> UnicodeSecurity.allowed_identifier?("paypal")
true

iex> UnicodeSecurity.allowed_identifier?("pay\u200Dpal")
false

audit(enumerable, options)

@spec audit(Enumerable.t(), keyword()) :: Enumerable.t()

Lazily audits an enumerable of candidate identifiers under one policy.

Returns a repeatable stream of UnicodeSecurity.BatchItem values with zero-based indexes and unchanged inputs. Valid options and an enumerable are required when this function is called; source items are read only as the stream is consumed. Each binary uses the same analysis as check/2. Nonbinary items become dangerous results with an :invalid_item_type reason. Producer exceptions propagate during consumption. The stream retains only its source and policy between items. Consume a finite prefix with Enum.take/2 when the source is unbounded.

check(input, options)

@spec check(binary(), keyword()) :: UnicodeSecurity.Result.t()

Analyzes an identifier and returns original-input facts and policy findings.

Requires type: :username, :tenant_slug, :organization_name, or :domain. policy selects :strict, :default, or :permissive. Usernames and tenant slugs default to :default; organization names default to :permissive; domains default to :strict. Optional allowed_scripts and denied_scripts accept ordinary script atoms from the pinned data. An omitted allowlist permits all scripts; an explicit empty list permits neutral scalars only. Multivalued Script_Extensions pass if a permitted candidate survives.

Username syntax accepts letters, marks, decimal digits and ASCII _-.; tenant slugs accept the same categories with only - punctuation. Organization names additionally accept pinned whitespace and punctuation categories. There are no first-character rules. Join controls receive normative context checks. Input is preserved without trimming, folding or rewriting.

Raw restriction facts remain unchanged. Restricted-character findings report original raw scalars independently of canonical membership, except username _-. and tenant -. Those exceptions also extend policy membership using punctuation-separated runs, preventing composition across separators. Organization whitespace and punctuation allowances affect syntax only.

Domain checks apply pinned UTS #46 hostname processing to the complete input, retain each original label and optional final root, then analyze normalized non-root labels separately. The whole-domain skeleton escapes literal dots and percent signs inside label skeletons so they cannot imitate boundaries. Processing is nontransitional with STD3, hyphen, bidi, joiner, and DNS length checks. An optional final root is accepted by the public hostname adapter. IDNA or hostname failures keep safe partial label facts but leave whole-name Unicode, ASCII, and skeleton values nil. Domain validity is independent of the selected security policy. domain.valid_idna? includes IDNA rules, DNS lengths, and hostname restrictions; invalid labels have nil ASCII forms.

Every binary with valid configuration returns UnicodeSecurity.Result, including empty, malformed UTF-8 and input over 4,096 original bytes. Partial results retain nil facts. For generic types, empty input is fully analyzed with a high-severity finding; for domains it is an invalid empty label. Positions are original zero-based byte/scalar indexes; global findings follow positional findings. Reasons sort by original byte offset, code, and deterministic details. See UnicodeSecurity.Reason for all codes, details and severities. The highest severity determines verdict. This check does not detect collisions or establish identity or authorization.

Raises ArgumentError for nonbinary input or invalid configuration, including missing type, duplicate/unknown options, malformed script lists and overlapping allow/deny lists. Configuration is validated before content analysis.

Examples

iex> UnicodeSecurity.check("alice-smith", type: :username).verdict
:safe

iex> UnicodeSecurity.check("", type: :username).verdict
:dangerous

iex> UnicodeSecurity.check("https://a", type: :domain).valid_input?
false

check_many(enumerable, options)

@spec check_many(Enumerable.t(), keyword()) :: UnicodeSecurity.BatchResult.t()

Eagerly audits an enumerable and returns ordered exact-duplicate and shared-skeleton groups alongside unchanged item results.

Requires the same policy options as audit/2. Every binary, including invalid input, may form an exact-duplicate group. Only valid inputs with computable skeletons enter collision groups. A collision requires distinct exact binary inputs; canonical variants can share a key without a confusable class. A collision's classes follow mixed, whole, then single-script precedence; its class is the first or :none for canonical-only key collisions. Collection reasons carry indexes and preset severity without changing any per-item result. Group findings describe relationships, not an identity or ownership decision.

compare(left, right, options \\ [])

@spec compare(binary(), binary(), term()) :: UnicodeSecurity.Comparison.t()

Returns comparison facts and original-input mapping evidence for two identifiers.

Accepts optional type: :username, :tenant_slug, :organization_name, or :domain. Domain comparison applies IDNA hostname processing to each label and ignores one optional final root for key equality. Its evidence records cover whole original labels and separators, including removed source text. Both inputs are validated left to right. Matching keys are comparison facts, not identity or authorization decisions. mappings has one entry per original scalar for generic types, including removed controls. Byte offsets and scalar indexes refer to original input; skeleton_spans use output scalar coordinates. :all denotes wholly neutral resolved scripts, and [] a mixed intersection. Invalid domain hostnames raise UnicodeSecurity.InvalidDomainError with the first validity reason, original byte_offset, and label_index. Malformed UTF-8 or oversized input still raises UnicodeSecurity.InvalidInputError.

Example

iex> UnicodeSecurity.compare("a", "A.", type: :domain).class
:none

conflict_key(input, options)

@spec conflict_key(binary(), term()) :: binary()

Returns the pinned skeleton key for a required identifier type.

Requires exactly type: :username, :tenant_slug, :organization_name, or :domain. The domain key uses validated IDNA labels and escaped skeleton payloads, with one optional final root ignored. Policy and script overrides are not accepted. The original UTF-8 input is validated with the 4,096-byte limit; there is no case folding, trimming, or policy check for generic types. Domain names must pass hostname validation; failures raise UnicodeSecurity.InvalidDomainError with reason, original byte_offset, and label_index. Malformed UTF-8 and oversized input still raise UnicodeSecurity.InvalidInputError. Persist the original input and Unicode version alongside any application-owned key index, and recompute versioned keys on data upgrades. The key alone does not decide identity or authorization.

Example

iex> UnicodeSecurity.conflict_key("BÜCHER.a.", type: :domain) == UnicodeSecurity.conflict_key("xn--bcher-kva.a", type: :domain)
true

conflicts(input, existing, options)

@spec conflicts(binary(), Enumerable.t(), term()) :: [UnicodeSecurity.Conflict.t()]

Returns all skeleton matches in enumerable order with original indexes and evidence.

Requires exactly the same type option as conflict_key/2. Candidate validation precedes collection validation; every existing value is visited, validated as a UTF-8 binary of at most 4,096 bytes, and may raise. Duplicates are retained. Results are advisory and must be paired with application storage checks; they are not policy verdicts or authorization decisions. Invalid domain candidates or visited hostnames raise UnicodeSecurity.InvalidDomainError with reason, byte_offset, and label_index; malformed UTF-8 or oversized input raises UnicodeSecurity.InvalidInputError.

conflicts?(input, existing, options)

@spec conflicts?(binary(), Enumerable.t(), term()) :: boolean()

Returns whether any visited existing identifier has the candidate's skeleton.

Requires exactly the same type option as conflict_key/2. Candidate validation precedes collection validation. The enumerable is consumed lazily and stops at the first match; malformed or nonbinary visited values raise, while unvisited values are untouched. This is an advisory check: concurrent storage changes may alter the result, and matching does not establish ownership or authorization. Invalid domain candidates or visited hostnames raise UnicodeSecurity.InvalidDomainError with reason, byte_offset, and label_index; malformed UTF-8 or oversized input raises UnicodeSecurity.InvalidInputError.

confusable?(left, right)

@spec confusable?(binary(), binary()) :: boolean()

Returns whether two original inputs share a skeleton but differ canonically.

Canonically equivalent strings, including identical strings, return false. Both inputs are validated left to right. This predicate is a comparison fact, not a safety or authorization verdict.

data_manifest()

@spec data_manifest() :: map()

Returns the provenance of the compiled Unicode data.

The manifest includes the release status and each source's logical filename, URL, version, byte size, SHA-256 digest, and status. All 21 Unicode 18.0.0 sources are final. This function performs no file or network access.

identifier_status(code)

@spec identifier_status(integer()) :: :allowed | :restricted

Returns the exact pinned UTS #39 Identifier_Status value for a Unicode scalar.

This scalar property is not canonically closed. A scalar with status :restricted can still occur in a string accepted by allowed_identifier?/1 when a canonically equivalent representation consists entirely of Allowed characters.

Raises ArgumentError unless the input is an integer Unicode scalar value.

identifier_types(code)

@spec identifier_types(integer()) :: [atom()]

Returns the sorted exact pinned UTS #39 Identifier_Type set for a Unicode scalar.

Values are lowercase snake-case atoms from a closed set. Unassigned scalars default to [:not_character], as declared by the pinned data. Raises ArgumentError unless the input is an integer Unicode scalar value.

mixed_number?(input)

@spec mixed_number?(binary()) :: boolean()

Detects multiple decimal number systems using UTS #39 revision 34, section 5.3.

Only Decimal_Number (Nd) scalars contribute a system, identified by their pinned zero character. Other numeric characters do not contribute systems; this does not validate identifier syntax or profile membership.

Accepts a UTF-8 binary of at most 4,096 bytes, with the same errors and original input offsets as scripts/1. Empty input returns false.

Examples

iex> UnicodeSecurity.mixed_number?("123")
false

iex> UnicodeSecurity.mixed_number?("1١")
true

mixed_script?(input)

@spec mixed_script?(binary()) :: boolean()

Detects mixed scripts using UTS #39 revision 34, section 5.1.

Intersects augmented Script_Extensions sets, including the Han/Latin, Japanese, Korean, and Han/Bopomofo combinations. Common and Inherited extension values are neutral. Empty and wholly neutral inputs return false. This is a script test, not an identifier validity or authorization decision.

Accepts a UTF-8 binary of at most 4,096 bytes, with the same errors and original input offsets as scripts/1.

Examples

iex> UnicodeSecurity.mixed_script?("раypal")
true

iex> UnicodeSecurity.mixed_script?("ねガ")
false

restriction_level(input)

@spec restriction_level(binary()) :: restriction_level()

Returns the first applicable UTS #39 revision 34, section 5.2 restriction level.

Ordered levels are :ascii, :single_script_restrictive, :highly_restrictive, :moderately_restrictive, :minimally_restrictive, and :unrestricted. The canonically closed General Security Profile is tested first: outside-profile inputs return :unrestricted, even when ASCII or single-script. Empty input returns :ascii.

Uses resolved augmented Script_Extensions, not the observed Script values returned by scripts/1. Recommended scripts are frozen from UAX #31 revision 44, Table 5 (Unicode 18.0.0 proposed); Bopomofo is Limited Use, not Recommended. This primitive adds no syntax rules or policy exceptions and is not a safety or authorization decision. Mixed numbers are detected separately.

Accepts a UTF-8 binary of at most 4,096 bytes, with the same errors and original input offsets as scripts/1.

Examples

iex> UnicodeSecurity.restriction_level("paypal")
:ascii

iex> UnicodeSecurity.restriction_level("aねガ")
:highly_restrictive

same_skeleton?(left, right)

@spec same_skeleton?(binary(), binary()) :: boolean()

Returns whether two validated original inputs have equal pinned skeleton keys.

Both inputs are validated left to right, even when identical. A matching key does not establish identity, intent, or authorization.

scripts(input)

@spec scripts(binary()) :: [atom()]

Returns the sorted unique Script property values observed in the original input.

Values are lowercase snake-case atoms from the pinned Unicode data, including :common, :inherited, and :unknown. This reports ordinary Script properties; use mixed_script?/1 for UTS #39 detection with Script_Extensions.

Accepts a UTF-8 binary of at most 4,096 bytes. Raises ArgumentError for nonbinary input and UnicodeSecurity.InvalidInputError for malformed UTF-8 or oversized input, with offsets in the original input.

Examples

iex> UnicodeSecurity.scripts("раypal")
[:cyrillic, :latin]

iex> UnicodeSecurity.scripts("a \u0301")
[:common, :inherited, :latin]

skeleton(input)

@spec skeleton(binary()) :: binary()

Returns the UTS #39 confusable skeleton using the pinned Unicode data.

The result is a comparison key only; do not use it as a canonical identifier, display value, replacement for the original input, or authorization decision. This milestone uses final Unicode 18.0.0 data.

UTS #39 revision 34 defines this as bidiSkeleton(LTR, input). It first applies the pinned Unicode Bidirectional Algorithm in isolation at paragraph level 0, including combining-mark placement and character-based mirroring. It then applies NFD, removes default-ignorable characters, maps MA prototypes, and reapplies NFD. It performs no case folding. ASCII characters can also map.

Each Unicode paragraph is treated as one line, without layout-dependent wrapping. X9 boundary neutrals and formatting controls are removed. Mirrored characters with no encoded mirror counterpart retain their code point. Skeletons of arbitrary bidirectional strings need not be idempotent.

Accepts a UTF-8 binary of at most 4,096 bytes. Raises ArgumentError for nonbinary input and UnicodeSecurity.InvalidInputError for malformed UTF-8 or oversized input. The returned skeleton may exceed the input byte limit.

Examples

iex> UnicodeSecurity.skeleton("pаypаl")
"paypal"

iex> UnicodeSecurity.skeleton("m")
"rn"

unicode_version()

@spec unicode_version() :: binary()

Returns the Unicode version used by the compiled final data; see data_manifest/0 for provenance.

uts39_revision()

@spec uts39_revision() :: non_neg_integer()

Returns the UTS #39 revision targeted by this milestone's skeleton implementation.