@push.rocks/smartnftables 3.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rust/src/tests.rs CHANGED
@@ -117,3 +117,103 @@ fn compiler_bounds_full_policy_and_keeps_directed_port_grants() {
117
117
  value.revision = 9_007_199_254_740_992;
118
118
  assert_eq!(value.prepare(), Err(Error::Invalid));
119
119
  }
120
+
121
+ fn exhausted<T>(name: &'static str, limit: usize, actual: usize) -> crate::Result<T> {
122
+ Err(Error::Exhausted(crate::Bound {
123
+ name,
124
+ limit,
125
+ actual,
126
+ }))
127
+ }
128
+
129
+ /// Every endpoint with 16 prefixes and `rules` distinct directed rules between
130
+ /// them: well-formed input whose only fault can be its size.
131
+ fn sized(endpoints: u32, rules: u16) -> Policy {
132
+ let mut value = policy();
133
+ let template = value.endpoints[0].clone();
134
+ value.endpoints = (0..endpoints)
135
+ .map(|index| {
136
+ let mut endpoint = template.clone();
137
+ endpoint.id = format!("e{index}");
138
+ endpoint.interface_index = 10 + index;
139
+ endpoint.interface_name = format!("w{index}");
140
+ endpoint.source_prefixes = (0..16)
141
+ .map(|part| format!("10.{}.{part}.0/24", 100 + index))
142
+ .collect();
143
+ endpoint
144
+ })
145
+ .collect();
146
+ let template = value.rules[0].clone();
147
+ value.rules = (0..rules)
148
+ .map(|index| {
149
+ let (from, to) = (
150
+ u32::from(index) % endpoints,
151
+ (u32::from(index) + 1) % endpoints,
152
+ );
153
+ let mut rule = template.clone();
154
+ rule.source_endpoint = Some(format!("e{from}"));
155
+ rule.destination_endpoint = Some(format!("e{to}"));
156
+ rule.source_prefix = format!("10.{}.0.0/24", 100 + from);
157
+ rule.destination_prefix = format!("10.{}.0.0/24", 100 + to);
158
+ rule.destination_port = Some(1000 + index);
159
+ rule
160
+ })
161
+ .collect();
162
+ value
163
+ }
164
+
165
+ #[test]
166
+ fn v1_capacity_refusals_name_their_bound_and_invalid_input_stays_invalid() {
167
+ assert_eq!(sized(2, 2).prepare().map(|_| ()), Ok(()));
168
+ // Within the schema's counts, the complete graph exceeds the byte and the
169
+ // operation budget; the complete program is measured, so both are exact.
170
+ assert_eq!(
171
+ sized(8, 16).prepare(),
172
+ exhausted("ruleBytes", 100_000, 186_952)
173
+ );
174
+ assert_eq!(sized(32, 128).prepare(), exhausted("operations", 768, 1667));
175
+ assert_eq!(sized(33, 16).prepare(), exhausted("endpoints", 32, 33));
176
+ assert_eq!(sized(8, 129).prepare(), exhausted("rules", 128, 129));
177
+ // Invalid input is never reported as capacity, whatever its size.
178
+ let mut value = sized(33, 129);
179
+ value.schema_version = 2;
180
+ assert_eq!(value.prepare(), Err(Error::Invalid));
181
+ let mut value = sized(2, 2);
182
+ value.endpoints[0].interface_name = "w".repeat(16);
183
+ assert_eq!(value.prepare(), Err(Error::Invalid));
184
+ let mut value = sized(2, 2);
185
+ value.endpoints[0]
186
+ .source_prefixes
187
+ .push("10.200.0.0/24".into());
188
+ assert_eq!(value.prepare(), Err(Error::Invalid));
189
+ }
190
+
191
+ #[test]
192
+ fn failure_responses_carry_the_refusal_reason_and_capacity_bound() {
193
+ let bound = crate::Bound {
194
+ name: "targetBytes",
195
+ limit: 212_000,
196
+ actual: 212_345,
197
+ };
198
+ assert_eq!(
199
+ crate::failure("7", Error::Exhausted(bound)),
200
+ serde_json::json!({"id":"7","success":false,
201
+ "error":"Managed policy exceeds the targetBytes capacity of 212000: it reached 212345.",
202
+ "errorCode":"EXHAUSTED","errorData":{"bound":"targetBytes","limit":212_000,"actual":212_345}})
203
+ );
204
+ assert_eq!(
205
+ crate::failure("8", Error::Invalid),
206
+ serde_json::json!({"id":"8","success":false,"error":"Managed policy input is invalid.","errorCode":"INVALID"})
207
+ );
208
+ for error in [
209
+ Error::Conflict,
210
+ Error::Permission,
211
+ Error::Protocol,
212
+ Error::Unavailable,
213
+ ] {
214
+ assert_eq!(
215
+ crate::failure("9", error),
216
+ serde_json::json!({"id":"9","success":false,"error":"Managed policy operation failed.","errorCode":error.code()})
217
+ );
218
+ }
219
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '3.0.0',
6
+ version: '4.0.1',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { snapshot, managedIpcBytes, ManagedNftablesError } from './classes.managednftables.js';
2
+ import { snapshot, managedIpcBytes, ManagedNftablesError, nativeError } from './classes.managednftables.js';
3
3
  import type { IManagedDockerForwardingOptions, IManagedDockerForwardingStatus, IManagedDockerForwardingPolicy, IPreparedManagedDockerForwardingPolicy, IManagedDockerForwardingTransition, IAppliedManagedDockerForwardingPolicy, TManagedDockerForwardingCommands } from './managed.docker.types.js';
4
4
 
5
5
  /** One node-level Docker DOCKER-USER contribution. Caller owns durable intents and handoff lifetimes. */
@@ -103,8 +103,7 @@ export class ManagedDockerForwarding {
103
103
  #track<T>(work: Promise<T>): Promise<T> {
104
104
  const owned = work.catch((error) => {
105
105
  if (this.#state !== 'closing') this.#state = 'failed-owned';
106
- const code = error instanceof plugins.smartrust.RustBridgeRequestError ? error.responseErrorCode : undefined;
107
- throw new ManagedNftablesError(typeof code === 'string' && /^[A-Z_]{1,64}$/.test(code) ? code : 'UNAVAILABLE');
106
+ throw nativeError(error);
108
107
  });
109
108
  this.#work = owned;
110
109
  void owned.then(() => { this.#work = null; }, () => { this.#work = null; });
@@ -1,9 +1,46 @@
1
1
  import * as plugins from './plugins.js';
2
- import type { IManagedNftOptions, IManagedNftPolicy, IManagedNftTransition, IAppliedManagedNftPolicy,
3
- IPreparedManagedNftPolicy, IManagedNftStatus, IManagedNftTransitionRelease, TManagedNftCommands, TManagedNftPolicy } from './managed.types.js';
2
+ import type { IManagedNftOptions, IManagedNftPolicy, IManagedNftTransition, IAppliedManagedNftPolicy, IPreparedManagedNftPolicy,
3
+ IManagedNftStatus, IManagedNftTransitionRelease, TManagedNftCommands, TManagedNftPolicy, IManagedNftCapacity,
4
+ TManagedNftCapacityBound } from './managed.types.js';
4
5
 
6
+ /** `INVALID`: the input is outside the contract. `EXHAUSTED`: the policy exceeds a
7
+ * capacity bound of the atomic replacement budget, and a smaller policy may fit.
8
+ * Both carry the native refusal text in `reason`; `EXHAUSTED` also names its
9
+ * bound in `details`. Every other code, and the facade's own rejections, carry
10
+ * neither. */
5
11
  export class ManagedNftablesError extends Error {
6
- constructor(public readonly code: string) { super(`Managed nftables ${code}.`); }
12
+ constructor(public readonly code: string, public readonly reason: string | null = null,
13
+ public readonly details: Readonly<IManagedNftCapacity> | null = null) {
14
+ super(reason === null ? `Managed nftables ${code}.` : `Managed nftables ${code}: ${reason}`);
15
+ }
16
+ }
17
+
18
+ const capacityBounds: ReadonlySet<string> = new Set<TManagedNftCapacityBound>(['ruleBytes', 'targetBytes', 'operations',
19
+ 'endpoints', 'links', 'rules', 'grants', 'hostGrants', 'workloadGrants', 'publishedPorts', 'localTcpPortOwners',
20
+ 'restoreRules', 'restoreBytes']);
21
+
22
+ function capacity(value: unknown): Readonly<IManagedNftCapacity> | null {
23
+ if (typeof value !== 'object' || value === null || Object.getPrototypeOf(value) !== Object.prototype
24
+ || Object.keys(value).sort().join(',') !== 'actual,bound,limit') return null;
25
+ const { bound, limit, actual } = value as Record<string, unknown>;
26
+ if (typeof bound !== 'string' || !capacityBounds.has(bound) || typeof limit !== 'number' || typeof actual !== 'number'
27
+ || !Number.isSafeInteger(limit) || !Number.isSafeInteger(actual) || limit < 0 || actual <= limit) return null;
28
+ return Object.freeze({ bound: bound as TManagedNftCapacityBound, limit, actual });
29
+ }
30
+
31
+ /** Maps a failed native request. An `INVALID` or `EXHAUSTED` response keeps the
32
+ * bounded native refusal text and, for `EXHAUSTED`, its bound; a refusal outside
33
+ * that shape is `PROTOCOL`. Other responses keep only their code, and a failure
34
+ * without a native code is `UNAVAILABLE`. */
35
+ export function nativeError(error: unknown): ManagedNftablesError {
36
+ if (!(error instanceof plugins.smartrust.RustBridgeRequestError)) return new ManagedNftablesError('UNAVAILABLE');
37
+ const { responseErrorCode: code, message: reason, responseErrorData: data } = error;
38
+ if (typeof code !== 'string' || !/^[A-Z_]{1,64}$/.test(code)) return new ManagedNftablesError('UNAVAILABLE');
39
+ if (code !== 'INVALID' && code !== 'EXHAUSTED') return new ManagedNftablesError(code);
40
+ if (!/^[\x20-\x7e]{1,256}$/.test(reason)) return new ManagedNftablesError('PROTOCOL');
41
+ if (code === 'INVALID') return data === undefined ? new ManagedNftablesError(code, reason) : new ManagedNftablesError('PROTOCOL');
42
+ const details = capacity(data);
43
+ return details ? new ManagedNftablesError(code, reason, details) : new ManagedNftablesError('PROTOCOL');
7
44
  }
8
45
 
9
46
  /** Bounds of the private native IPC. A status carries up to three complete policies (applied,
@@ -240,16 +277,17 @@ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPoli
240
277
  #track<T>(work: Promise<T>, method: keyof TManagedNftCommands<TPolicy>): Promise<T> {
241
278
  const owned = work.catch((error) => {
242
279
  if (this.#state !== 'closing') this.#state = 'failed-owned';
243
- const code = error instanceof plugins.smartrust.RustBridgeRequestError ? error.responseErrorCode : undefined;
244
- // Only a native compiler rejection or explicit INVALID response proves
245
- // effect-free validation. Other native errors can follow lost ownership
280
+ const failure = nativeError(error);
281
+ // Only a native compiler rejection or explicit INVALID or EXHAUSTED
282
+ // response proves effect-free validation: the native owner refuses both
283
+ // before any kernel work. Other native errors can follow lost ownership
246
284
  // or admitted kernel work. Recovery stays available; confidence is lost.
247
285
  const validation = error instanceof plugins.smartrust.RustBridgeRequestError
248
286
  && error.code === 'ERR_RUST_BRIDGE_REQUEST_RUST_RESPONSE'
249
- && (method === 'preparePolicy' || code === 'INVALID');
287
+ && (method === 'preparePolicy' || failure.code === 'INVALID' || failure.code === 'EXHAUSTED');
250
288
  if (!validation && (this.#state !== 'closing'
251
289
  || ['reconcilePolicy', 'releasePolicy', 'releasePolicyTransition', 'detachPolicy'].includes(method))) this.#fail();
252
- throw new ManagedNftablesError(typeof code === 'string' && /^[A-Z_]{1,64}$/.test(code) ? code : 'UNAVAILABLE');
290
+ throw failure;
253
291
  });
254
292
  this.#work = owned;
255
293
  void owned.then(() => { this.#work = null; }, () => { this.#work = null; });
@@ -172,6 +172,8 @@ export interface IManagedNftHostTransitScopeV2 {
172
172
  kind: 'hostTransit';
173
173
  protection: IManagedNftProtectionV2;
174
174
  handoffs: Array<{ link: IManagedNftLocalLinkV2; allocations: IManagedNftHandoffAllocationV2[] }>;
175
+ /** Its addresses need not lie in `protection.prefixes`; the host barrier protects each
176
+ * uncovered one as a `/32` destination. Handoff addresses must be protected. */
175
177
  uplink: IManagedNftLocalLinkV2;
176
178
  /** Exact current address present on uplink. No masquerade or default-route inference. */
177
179
  snatAddress: string;
@@ -93,6 +93,23 @@ export interface IManagedNftOptions {
93
93
 
94
94
  export type TManagedNftPolicy = IManagedNftPolicy | IManagedNftPolicyV2;
95
95
 
96
+ /** Capacity bounds of the atomic replacement budget. The compiled target's
97
+ * `ruleBytes` (100,000), `targetBytes` (212,000) and `operations` (768), the input
98
+ * counts that exist to hold it (`endpoints`, `links`, `rules`, `grants`,
99
+ * `hostGrants`, `workloadGrants`, `publishedPorts`, `localTcpPortOwners`) and the
100
+ * Docker forwarding contribution's `restoreRules` (192) and `restoreBytes` (100,000). */
101
+ export type TManagedNftCapacityBound = 'ruleBytes' | 'targetBytes' | 'operations' | 'endpoints' | 'links' | 'rules'
102
+ | 'grants' | 'hostGrants' | 'workloadGrants' | 'publishedPorts' | 'localTcpPortOwners' | 'restoreRules' | 'restoreBytes';
103
+
104
+ /** The bound an `EXHAUSTED` refusal names. `actual` is always above `limit`. It is
105
+ * exact for input counts and the schema-v1 budget; the other compiled budgets stop
106
+ * at the first message or rule past the limit, so the policy needs at least `actual`. */
107
+ export interface IManagedNftCapacity {
108
+ bound: TManagedNftCapacityBound;
109
+ limit: number;
110
+ actual: number;
111
+ }
112
+
96
113
  export type TManagedNftCommands<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> = {
97
114
  preparePolicy: { params: TPolicy; result: IPreparedManagedNftPolicy<TPolicy> };
98
115
  openOwner: { params: Pick<IManagedNftOptions, 'ownerId' | 'instanceId' | 'tableName'>; result: IManagedNftStatus<TPolicy> };