Permissions-Policy Builder — Zig source
Build a Permissions-Policy header interactively. Control which browser features (camera, microphone, geolocation, etc.) your site can use.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! permissions-policy — Permissions-Policy header builder / parser.
//!
//! Language: Zig 0.13+ (standard library only)
//! Ported from: src/lib/permissions-policy.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! The Permissions-Policy header is a comma-separated list of directives:
//! Permissions-Policy: geolocation=(self), camera=(), microphone=*, usb=(https://a.example)
//! Each directive maps a browser feature to an allowlist. An empty allowlist
//! `()` disables the feature outright; `*` allows it everywhere; `self` limits
//! it to the page's own origin; anything else is a space-separated origin list.
//!
//! A policy is modeled here as a map of feature -> allowlist slice:
//! geolocation -> ["self"], camera -> [], usb -> ["https://a.example"]
//! - [] => camera=() (disabled)
//! - ["*"] => microphone=* (every origin)
//! - ["self"] => geolocation=(self)
//! - [origins...] => usb=(https://a.example https://b.example)
//! Features absent from the map are absent from the header (browser default).
//!
//! The TS reference leans on regular expressions; Zig has no regex in the
//! standard library, so the two patterns are hand-rolled as predicates:
//! FEATURE_NAME_RE /^[a-z][a-z0-9-]*$/ -> isValidFeatureName
//! ORIGIN_RE /^(https?:\/\/|https?:)[\w.-]+(:\d+)?$/i -> isValidOrigin
const std = @import("std");
/// Whitespace set used for trimming. Mirrors what JS `String.prototype.trim`
/// strips for the inputs this tool sees.
const WS = " \t\r\n\x0b\x0c";
// MARK: - Types
pub const PrivacyImpact = enum {
high,
medium,
low,
/// Scoring weight: high 3, medium 2, low 1.
pub fn weight(self: PrivacyImpact) f64 {
return switch (self) {
.high => 3,
.medium => 2,
.low => 1,
};
}
};
pub const FeatureInfo = struct {
/// The directive token used in the header, e.g. `geolocation`.
name: []const u8,
description: []const u8,
privacy_impact: PrivacyImpact,
/// What browsers do when the feature is absent from the policy.
default_browser_behavior: []const u8,
};
// MARK: - Feature catalog
pub const FEATURES = [_]FeatureInfo{
// --- high privacy impact -------------------------------------------------
.{ .name = "camera", .description = "Access the device camera for photos / video calls.", .privacy_impact = .high, .default_browser_behavior = "Same-origin only; prompts the user." },
.{ .name = "microphone", .description = "Capture audio from the device microphone.", .privacy_impact = .high, .default_browser_behavior = "Same-origin only; prompts the user." },
.{ .name = "geolocation", .description = "Read the precise GPS location of the visitor.", .privacy_impact = .high, .default_browser_behavior = "Same-origin only; prompts the user." },
.{ .name = "display-capture", .description = "Screen / window sharing via getDisplayMedia.", .privacy_impact = .high, .default_browser_behavior = "Same-origin only; prompts the user." },
.{ .name = "idle-detection", .description = "Detects when the user is away from the device — reveals usage patterns.", .privacy_impact = .high, .default_browser_behavior = "Disabled; prompts the user." },
.{ .name = "serial", .description = "Talk to serial devices (Arduinos, POS terminals) over a physical port.", .privacy_impact = .high, .default_browser_behavior = "Disabled; prompts the user." },
.{ .name = "usb", .description = "WebUSB — direct access to connected USB devices.", .privacy_impact = .high, .default_browser_behavior = "Disabled; prompts the user." },
.{ .name = "hid", .description = "Human Interface Devices — raw access to unusual keyboards, gamepads, sensors.", .privacy_impact = .high, .default_browser_behavior = "Disabled; prompts the user." },
.{ .name = "xr-spatial-tracking", .description = "Tracks head / hand position in WebXR sessions.", .privacy_impact = .high, .default_browser_behavior = "Same-origin only; prompts the user." },
// --- medium privacy impact -----------------------------------------------
.{ .name = "accelerometer", .description = "Device motion sensor — can fingerprint and infer behaviour.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "ambient-light-sensor", .description = "Reads ambient light level around the device.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "battery", .description = "Battery Status API — a classic fingerprinting vector.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "gyroscope", .description = "Device orientation sensor — fingerprinting and behaviour inference.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "magnetometer", .description = "Compass readings — can leak details of the user's surroundings.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "keyboard-map", .description = "Reads the physical keyboard layout — a small but real fingerprint.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "gamepad", .description = "Enumerates connected controllers and their button state.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "midi", .description = "Web MIDI — access to attached music hardware.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only; prompts the user." },
.{ .name = "payment", .description = "Payment Request API — can expose stored payment handles.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "publickey-credentials-get", .description = "WebAuthn credential requests.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "screen-wake-lock", .description = "Keeps the screen awake — drains battery and signals intent.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "speaker-selection", .description = "Enumerates and switches audio output devices.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "web-share", .description = "Invokes the OS share sheet with chosen content.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "encrypted-media", .description = "DRM (EME) — playback identity can be correlated.", .privacy_impact = .medium, .default_browser_behavior = "Same-origin only." },
.{ .name = "document-domain", .description = "Let frames relax the same-origin policy via document.domain.", .privacy_impact = .medium, .default_browser_behavior = "Allowed in same-origin pages; deprecated." },
// --- low privacy impact --------------------------------------------------
.{ .name = "autoplay", .description = "Autoplay media with/without sound — not a data leak, an annoyance knob.", .privacy_impact = .low, .default_browser_behavior = "Muted autoplay allowed; audible blocked." },
.{ .name = "cross-origin-isolated", .description = "COOP/COEP isolation for SharedArrayBuffer — hardens the page.", .privacy_impact = .low, .default_browser_behavior = "Not isolated." },
.{ .name = "fullscreen", .description = "Element.requestFullscreen().", .privacy_impact = .low, .default_browser_behavior = "Same-origin only." },
.{ .name = "navigation-override", .description = "Lets a frame intercept its own top-level navigations.", .privacy_impact = .low, .default_browser_behavior = "Disabled." },
.{ .name = "picture-in-picture", .description = "Floating always-on-top video window.", .privacy_impact = .low, .default_browser_behavior = "Same-origin only." },
};
/// Look up a feature in the catalog by directive token.
pub fn findFeature(name: []const u8) ?FeatureInfo {
for (FEATURES) |feature| {
if (std.mem.eql(u8, feature.name, name)) return feature;
}
return null;
}
// MARK: - Policy (feature -> allowlist)
/// A feature map. Backed by an insertion-ordered hash map so it behaves like
/// the plain JS object the TS reference uses: re-assigning a feature replaces
/// the previous allowlist rather than appending a second directive.
///
/// All keys and tokens are owned by an internal arena — `deinit` frees the lot.
pub const Policy = struct {
arena: std.heap.ArenaAllocator,
map: std.StringArrayHashMapUnmanaged([]const []const u8) = .{},
pub fn init(child_allocator: std.mem.Allocator) Policy {
return .{ .arena = std.heap.ArenaAllocator.init(child_allocator) };
}
pub fn deinit(self: *Policy) void {
self.arena.deinit();
self.* = undefined;
}
/// Copy `name` and `allowlist` into the policy. Replaces any existing entry.
pub fn put(self: *Policy, name: []const u8, allowlist: []const []const u8) !void {
const a = self.arena.allocator();
const owned_name = try a.dupe(u8, name);
const owned_tokens = try a.alloc([]const u8, allowlist.len);
for (allowlist, 0..) |token, i| owned_tokens[i] = try a.dupe(u8, token);
try self.map.put(a, owned_name, owned_tokens);
}
/// `null` when the feature is absent (browser default applies).
/// An empty (non-null) slice means `feature=()` — explicitly disabled.
pub fn get(self: Policy, name: []const u8) ?[]const []const u8 {
return self.map.get(name);
}
pub fn count(self: Policy) usize {
return self.map.count();
}
/// Feature names in insertion order.
pub fn names(self: Policy) []const []const u8 {
return self.map.keys();
}
};
// MARK: - Token predicates (the hand-rolled regexes)
/// `/^[a-z][a-z0-9-]*$/` — a lowercase directive token.
pub fn isValidFeatureName(name: []const u8) bool {
if (name.len == 0) return false;
if (!std.ascii.isLower(name[0])) return false;
for (name[1..]) |c| {
if (!std.ascii.isLower(c) and !std.ascii.isDigit(c) and c != '-') return false;
}
return true;
}
fn isHostChar(c: u8) bool {
// `\w` plus `.` and `-`; `\w` is [A-Za-z0-9_].
return std.ascii.isAlphanumeric(c) or c == '_' or c == '.' or c == '-';
}
fn stripPrefixIgnoreCase(text: []const u8, prefix: []const u8) ?[]const u8 {
if (text.len < prefix.len) return null;
if (!std.ascii.eqlIgnoreCase(text[0..prefix.len], prefix)) return null;
return text[prefix.len..];
}
/// `/^(https?:\/\/|https?:)[\w.-]+(:\d+)?$/i` — a scheme-qualified origin.
/// The alternation is ordered exactly as in the TS regex: the `//` forms are
/// tried first, so `https://a.example` consumes the slashes rather than
/// treating them as host characters.
pub fn isValidOrigin(token: []const u8) bool {
const after_scheme =
stripPrefixIgnoreCase(token, "https://") orelse
stripPrefixIgnoreCase(token, "http://") orelse
stripPrefixIgnoreCase(token, "https:") orelse
stripPrefixIgnoreCase(token, "http:") orelse
return false;
var i: usize = 0;
while (i < after_scheme.len and isHostChar(after_scheme[i])) : (i += 1) {}
if (i == 0) return false; // `[\w.-]+` needs at least one character
if (i == after_scheme.len) return true; // no port
if (after_scheme[i] != ':') return false;
i += 1;
if (i == after_scheme.len) return false; // `:` with no digits
while (i < after_scheme.len) : (i += 1) {
if (!std.ascii.isDigit(after_scheme[i])) return false;
}
return true;
}
/// A `self` / `*` / origin allowlist token is valid; anything else is not.
fn isValidAllowlistToken(token: []const u8) bool {
return std.mem.eql(u8, token, "self") or std.mem.eql(u8, token, "*") or isValidOrigin(token);
}
/// `/^"(.*)"$/` — unwrap a quoted origin, leaving anything else untouched.
fn unquote(token: []const u8) []const u8 {
if (token.len >= 2 and token[0] == '"' and token[token.len - 1] == '"') {
return token[1 .. token.len - 1];
}
return token;
}
fn trim(text: []const u8) []const u8 {
return std.mem.trim(u8, text, WS);
}
/// Drop an optional `Permissions-Policy:` prefix — `/^permissions-policy\s*:\s*/i`.
fn stripHeaderName(raw: []const u8) []const u8 {
const trimmed = trim(raw);
const after_name = stripPrefixIgnoreCase(trimmed, "permissions-policy") orelse return trimmed;
const after_ws = std.mem.trimLeft(u8, after_name, WS);
if (after_ws.len == 0 or after_ws[0] != ':') return trimmed; // no colon — not the prefix
return std.mem.trimLeft(u8, after_ws[1..], WS);
}
// MARK: - Build
/// Render one allowlist: `*` stays bare, everything else is parenthesised.
fn writeAllowlist(writer: anytype, tokens: []const []const u8) !void {
if (tokens.len == 1 and std.mem.eql(u8, tokens[0], "*")) {
try writer.writeAll("*");
return;
}
try writer.writeByte('(');
for (tokens, 0..) |token, i| {
if (i > 0) try writer.writeByte(' ');
try writer.writeAll(token);
}
try writer.writeByte(')');
}
fn lessThanAscii(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
/// Assemble a Permissions-Policy header value from a feature map.
/// Directives are emitted in sorted order, matching `Object.keys().sort()`.
/// Caller owns the returned slice.
pub fn buildPermissionsPolicy(allocator: std.mem.Allocator, policy: Policy) ![]u8 {
const sorted = try allocator.dupe([]const u8, policy.names());
defer allocator.free(sorted);
std.mem.sort([]const u8, sorted, {}, lessThanAscii);
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
const writer = out.writer();
for (sorted, 0..) |name, i| {
if (i > 0) try writer.writeAll(", ");
try writer.writeAll(name);
try writer.writeByte('=');
try writeAllowlist(writer, policy.get(name).?);
}
return out.toOwnedSlice();
}
// MARK: - Parse
/// Parse a Permissions-Policy header value back into a feature map.
/// Accepts an optional `Permissions-Policy:` prefix. Returns `null` when the
/// syntax is malformed (bad directive, missing allowlist, bad token) — the
/// same contract as the TS reference returning `null`.
/// Caller owns the returned policy and must `deinit` it.
pub fn parsePermissionsPolicy(allocator: std.mem.Allocator, header: []const u8) !?Policy {
const cleaned = stripHeaderName(header);
if (cleaned.len == 0) return null;
var policy = Policy.init(allocator);
errdefer policy.deinit();
var scratch = std.ArrayList([]const u8).init(allocator);
defer scratch.deinit();
var directives = std.mem.splitScalar(u8, cleaned, ',');
while (directives.next()) |raw_directive| {
const directive = trim(raw_directive);
if (directive.len == 0) return null;
const eq = std.mem.indexOfScalar(u8, directive, '=') orelse return null;
if (eq == 0) return null; // `=allowlist` has no feature name
const name = trim(directive[0..eq]);
const value = trim(directive[eq + 1 ..]);
if (!isValidFeatureName(name)) return null;
if (std.mem.eql(u8, value, "*") or std.mem.eql(u8, value, "self")) {
try policy.put(name, &[_][]const u8{value});
continue;
}
if (value.len < 2 or value[0] != '(' or value[value.len - 1] != ')') return null;
const inner = trim(value[1 .. value.len - 1]);
if (inner.len == 0) {
try policy.put(name, &[_][]const u8{}); // () = disabled
continue;
}
scratch.clearRetainingCapacity();
var tokens = std.mem.tokenizeAny(u8, inner, WS);
while (tokens.next()) |raw_token| {
const token = unquote(raw_token);
if (!isValidAllowlistToken(token)) return null;
try scratch.append(token);
}
try policy.put(name, scratch.items);
}
return policy;
}
// MARK: - Validate
pub const Validation = struct {
allocator: std.mem.Allocator,
errors: []const []const u8,
pub fn valid(self: Validation) bool {
return self.errors.len == 0;
}
pub fn deinit(self: *Validation) void {
for (self.errors) |message| self.allocator.free(message);
self.allocator.free(self.errors);
self.* = undefined;
}
};
/// Syntax-check a header the same way `parsePermissionsPolicy` does, but
/// collect human-readable messages instead of bailing out on the first fault.
/// Caller owns the returned validation and must `deinit` it.
pub fn validatePermissionsPolicy(allocator: std.mem.Allocator, header: []const u8) !Validation {
var errors = std.ArrayList([]const u8).init(allocator);
errdefer {
for (errors.items) |message| allocator.free(message);
errors.deinit();
}
const cleaned = stripHeaderName(header);
if (cleaned.len == 0) {
try errors.append(try allocator.dupe(u8, "Header is empty."));
return .{ .allocator = allocator, .errors = try errors.toOwnedSlice() };
}
var index: usize = 0;
var directives = std.mem.splitScalar(u8, cleaned, ',');
while (directives.next()) |raw_directive| : (index += 1) {
const directive = trim(raw_directive);
const where = index + 1;
if (directive.len == 0) {
try errors.append(try std.fmt.allocPrint(allocator, "Directive {d}: empty (stray comma?).", .{where}));
continue;
}
const eq = std.mem.indexOfScalar(u8, directive, '=') orelse {
try errors.append(try std.fmt.allocPrint(allocator, "Directive {d}: expected `feature=allowlist`, got `{s}`.", .{ where, directive }));
continue;
};
if (eq == 0) {
try errors.append(try std.fmt.allocPrint(allocator, "Directive {d}: expected `feature=allowlist`, got `{s}`.", .{ where, directive }));
continue;
}
const name = trim(directive[0..eq]);
const value = trim(directive[eq + 1 ..]);
if (!isValidFeatureName(name)) {
try errors.append(try std.fmt.allocPrint(allocator, "Directive {d}: `{s}` is not a valid feature name.", .{ where, name }));
continue;
}
if (std.mem.eql(u8, value, "*") or std.mem.eql(u8, value, "self")) continue;
if (value.len < 2 or value[0] != '(' or value[value.len - 1] != ')') {
try errors.append(try std.fmt.allocPrint(allocator, "Directive {d}: `{s}` allowlist must be `*`, `self`, or `(...)` — got `{s}`.", .{ where, name, value }));
continue;
}
const inner = trim(value[1 .. value.len - 1]);
if (inner.len == 0) continue; // () = disabled, valid
var tokens = std.mem.tokenizeAny(u8, inner, WS);
while (tokens.next()) |raw_token| {
if (!isValidAllowlistToken(unquote(raw_token))) {
try errors.append(try std.fmt.allocPrint(allocator, "Directive {d}: `{s}` has an invalid allowlist token `{s}`.", .{ where, name, raw_token }));
}
}
}
return .{ .allocator = allocator, .errors = try errors.toOwnedSlice() };
}
// MARK: - Score
/// Privacy score 0-100 for a policy: how much it locks down the catalog.
/// Each feature is weighted by privacy impact (high 3, medium 2, low 1).
/// Disabled `()` earns full credit, `self` or an origin list half, `*` or
/// "absent from the policy" none (the browser default stays in force).
pub fn privacyScore(policy: Policy) u32 {
var earned: f64 = 0;
var possible: f64 = 0;
for (FEATURES) |feature| {
const w = feature.privacy_impact.weight();
possible += w;
const allowlist = policy.get(feature.name) orelse continue; // absent — no credit
if (allowlist.len == 0) {
earned += w; // () disabled
} else if (!(allowlist.len == 1 and std.mem.eql(u8, allowlist[0], "*"))) {
earned += w / 2; // self / origins
}
}
return @intFromFloat(@round((earned / possible) * 100));
}
// MARK: - Demo
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var policy = Policy.init(allocator);
defer policy.deinit();
try policy.put("geolocation", &[_][]const u8{"self"});
try policy.put("camera", &[_][]const u8{});
try policy.put("microphone", &[_][]const u8{"*"});
try policy.put("usb", &[_][]const u8{"https://a.example"});
const header = try buildPermissionsPolicy(allocator, policy);
defer allocator.free(header);
const stdout = std.io.getStdOut().writer();
try stdout.print("Permissions-Policy: {s}\n", .{header});
try stdout.print("privacy score: {d}/100\n", .{privacyScore(policy)});
var round_trip = (try parsePermissionsPolicy(allocator, header)) orelse {
try stdout.print("parse failed\n", .{});
return;
};
defer round_trip.deinit();
try stdout.print("parsed {d} directives\n", .{round_trip.count()});
var check = try validatePermissionsPolicy(allocator, "camera=(), geolocation=(self, usb=*");
defer check.deinit();
try stdout.print("valid: {}\n", .{check.valid()});
for (check.errors) |message| try stdout.print(" - {s}\n", .{message});
}
// MARK: - Tests (shared vectors with src/lib/permissions-policy.test.ts)
test "buildPermissionsPolicy sorts directives and formats allowlists" {
const allocator = std.testing.allocator;
var policy = Policy.init(allocator);
defer policy.deinit();
try policy.put("geolocation", &[_][]const u8{"self"});
try policy.put("camera", &[_][]const u8{});
try policy.put("microphone", &[_][]const u8{"*"});
const header = try buildPermissionsPolicy(allocator, policy);
defer allocator.free(header);
try std.testing.expectEqualStrings("camera=(), geolocation=(self), microphone=*", header);
}
test "parsePermissionsPolicy round-trips and strips the header name" {
const allocator = std.testing.allocator;
var policy = (try parsePermissionsPolicy(allocator, "Permissions-Policy: camera=(), usb=(https://a.example)")).?;
defer policy.deinit();
try std.testing.expectEqual(@as(usize, 0), policy.get("camera").?.len);
try std.testing.expectEqualStrings("https://a.example", policy.get("usb").?[0]);
}
test "parsePermissionsPolicy rejects malformed input" {
const allocator = std.testing.allocator;
try std.testing.expect((try parsePermissionsPolicy(allocator, "")) == null);
try std.testing.expect((try parsePermissionsPolicy(allocator, "camera")) == null);
try std.testing.expect((try parsePermissionsPolicy(allocator, "=()")) == null);
try std.testing.expect((try parsePermissionsPolicy(allocator, "Camera=()")) == null); // uppercase name
try std.testing.expect((try parsePermissionsPolicy(allocator, "camera=(nope)")) == null);
try std.testing.expect((try parsePermissionsPolicy(allocator, "camera=(), ")) == null); // stray comma
}
test "isValidOrigin mirrors ORIGIN_RE" {
try std.testing.expect(isValidOrigin("https://a.example"));
try std.testing.expect(isValidOrigin("http://a.example:8080"));
try std.testing.expect(isValidOrigin("https:a.example"));
try std.testing.expect(!isValidOrigin("a.example")); // no scheme
try std.testing.expect(!isValidOrigin("https://")); // no host
try std.testing.expect(!isValidOrigin("https://a.example:")); // port with no digits
try std.testing.expect(!isValidOrigin("https://a.example:80x")); // non-digit port
}
test "validatePermissionsPolicy reports every fault" {
const allocator = std.testing.allocator;
var check = try validatePermissionsPolicy(allocator, "camera=(), Bad=(), usb=nope");
defer check.deinit();
try std.testing.expect(!check.valid());
try std.testing.expectEqual(@as(usize, 2), check.errors.len);
var empty = try validatePermissionsPolicy(allocator, " ");
defer empty.deinit();
try std.testing.expect(!empty.valid());
try std.testing.expectEqualStrings("Header is empty.", empty.errors[0]);
}
test "privacyScore weights by impact" {
const allocator = std.testing.allocator;
var empty = Policy.init(allocator);
defer empty.deinit();
try std.testing.expectEqual(@as(u32, 0), privacyScore(empty));
// Every catalog feature disabled => full credit.
var locked = Policy.init(allocator);
defer locked.deinit();
for (FEATURES) |feature| try locked.put(feature.name, &[_][]const u8{});
try std.testing.expectEqual(@as(u32, 100), privacyScore(locked));
// `*` earns nothing; `self` earns half.
var mixed = Policy.init(allocator);
defer mixed.deinit();
try mixed.put("camera", &[_][]const u8{"*"});
try std.testing.expectEqual(@as(u32, 0), privacyScore(mixed));
try mixed.put("camera", &[_][]const u8{"self"});
try std.testing.expect(privacyScore(mixed) > 0);
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →