text.mask
Mask all but the last n characters of a card, account or phone number, optionally keeping separators.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript, Python and Rust.
What it does
Replaces every character except the last `visible` with `maskChar`: "4242424242424242" with 4 visible is "************4242".
## Separators
For example
mask(4242424242424242, 4, *, false)→ ************4242 a card number shows its last fourmask(4242 4242 4242 4242, 4, *, true)→ **** **** **** 4242 separators kept: the grouping survives and still four digits showmask(4242 4242 4242 4242, 4, *, false)→ ***************4242 separators not kept: spaces are masked and count as characters
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn mask(value: &str, visible: i64, mask_char: &str, keep_separators: bool) -> String
| value | string | the text to mask |
| visible | int | how many characters to leave showing at the end, 0 or more |
| mask_char | string | exactly one character, usually "*" |
| keep_separators | bool | leave spaces and hyphens in place and do not count them |
| returns | string | the same number of characters as value |
Your code names it in one line, in the file that uses it
fune!(text.mask@^1); // then call mask(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// Only these two are separators: they are how card and sort-code numbers are
/// printed.
fn is_separator(ch: char) -> bool {
ch == ' ' || ch == '-'
}
/// Mask all but the last `visible` characters of `value` with `mask_char`.
///
/// Characters are Unicode code points (`char`), so an emoji counts once and
/// the result has as many code points as the input.
///
/// # Panics
/// Panics if `visible` is negative or `mask_char` is not exactly one character.
pub fn mask(value: &str, visible: i64, mask_char: &str, keep_separators: bool) -> String {
if visible < 0 {
panic!("visible must be 0 or greater, received {}", visible);
}
let mut mask_chars = mask_char.chars();
let replacement = match (mask_chars.next(), mask_chars.next()) {
(Some(c), None) => c,
_ => panic!("maskChar must be exactly one character, received \"{}\"", mask_char),
};
let chars: Vec<char> = value.chars().collect();
let mut out: Vec<char> = vec![replacement; chars.len()];
let mut shown: i64 = 0;
// Walk from the end so "the last n" is counted without a second pass.
for i in (0..chars.len()).rev() {
let ch = chars[i];
if keep_separators && is_separator(ch) {
out[i] = ch;
} else if shown < visible {
out[i] = ch;
shown += 1;
}
}
out.into_iter().collect()
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Str(mask(
args[0].as_str(),
args[1].as_i64(),
args[2].as_str(),
args[3].as_bool(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add text.mask
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./text.mask-1.0.0-rust.fune, or fetch it from a terminal with fune pull text.mask@1.0.0:rust.
The whole function, every language, is one file too: text.mask-1.0.0.fune, 10,511 bytes, sha256 e1bbb7d0741d771c6ccf2ae30859e642453a9ee706208c1183714ba54a1668fa. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before text.mask
after — your function gets the result and the arguments, and returns the final result.
// fune: after text.mask
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show text.mask --steps.
// fune: step text.mask after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a card number shows its last four | 4242424242424242, 4, *, false | → | ************4242 |
| separators kept: the grouping survives and still four digits show | 4242 4242 4242 4242, 4, *, true | → | **** **** **** 4242 |
| separators not kept: spaces are masked and count as characters | 4242 4242 4242 4242, 4, *, false | → | ***************4242 |
| separators kept, the visible count skips over a separator | 1234 5678, 5, *, true | → | ***4 5678 |
| a hyphenated sort code with a different mask character | 12-34-56, 2, #, true | → | ##-##-56 |
| zero visible masks everything | 12345678, 0, *, false | → | ******** |
| visible equal to the length leaves the value alone | 1234, 4, *, false | → | 1234 |
| visible past the length leaves the value alone, unpadded | 12, 4, *, false | → | 12 |
| the empty string stays empty | , 4, *, true | → | |
| dots are not separators: they are masked | a.b.c, 1, *, true | → | ****c |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| accented letters count as one character each | ÄÖÜß, 1, •, false | → | •••ß |
| an emoji is one character, not two UTF-16 units | a😀b, 1, *, false | → | **b |
| an emoji is masked as a single mask character | 😀😀x, 2, *, false | → | *😀x |
| a negative visible count is an error | 1234, -1, *, false | → | error: visible must be 0 or greater |
| an empty mask character is an error | 1234, 2, , false | → | error: maskChar must be exactly one character |
| a two-character mask would change the length, so it is an error | 1234, 2, **, false | → | error: maskChar must be exactly one character |
More from the author
With `keepSeparators` on, ASCII spaces and hyphens stay where they are and are not counted, so a card printed in groups keeps its shape and still shows its last four digits: "4242 4242 4242 4242" becomes "**** **** **** 4242", and the sort code "12-34-56" with 2 visible becomes "**-**-56". Only those two characters are separators; dots, slashes and brackets are masked like anything else, because they are just as often part of the secret.
With it off, separators are ordinary characters: they are masked and they count towards `visible`, so the output never reveals how a value was grouped.
## Counting
Characters are Unicode code points in all three languages, so an accented letter or an emoji counts once, and the result always has the same number of code points as the input. A letter written as a base letter plus a combining accent is two code points and is masked as two.
## Edge cases
- `visible` of 0 masks everything; `visible` at or above the length returns the value unchanged. Nothing is padded: a short value stays short. - A negative `visible`, or a `maskChar` that is not exactly one code point, is an error.
## What masking is not
Masking is for display. The digits you show must be ones you are allowed to show - PCI DSS permits at most the first six and last four of a card number - and the full value must not be sent to the page at all if it is not needed there. This function does not know what the value is.
Files
| Path | Bytes |
|---|---|
| README.md | 1,599 |
| impl/python.py | 1,561 |
| impl/rust.rs | 1,595 |
| impl/typescript.ts | 1,521 |
| vectors.json | 2,075 |