@vlydev/cs2-masked-inspect 1.0.0 → 1.0.7

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.
@@ -0,0 +1,30 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish-npm:
9
+ runs-on: ubuntu-latest
10
+ environment: npm
11
+ permissions:
12
+ id-token: write # required for npm trusted publishing (OIDC)
13
+ contents: read
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Setup Node.js
19
+ uses: actions/setup-node@v4
20
+ with:
21
+ node-version: "24"
22
+ registry-url: "https://registry.npmjs.org"
23
+
24
+ - name: Set version from tag
25
+ run: |
26
+ VERSION="${GITHUB_REF_NAME#v}"
27
+ npm version "$VERSION" --no-git-tag-version
28
+
29
+ - name: Publish to npm
30
+ run: npm publish --access public --provenance
package/README.md CHANGED
@@ -98,6 +98,24 @@ InspectLink.isClassic(classicUrl); // true
98
98
 
99
99
  ---
100
100
 
101
+ ## Validation rules
102
+
103
+ `deserialize()` enforces:
104
+
105
+ | Rule | Limit | Error |
106
+ |------|-------|-------|
107
+ | Hex payload length | max 4,096 characters | `RangeError` |
108
+ | Protobuf field count | max 100 per message | `RangeError` |
109
+
110
+ `serialize()` enforces:
111
+
112
+ | Field | Constraint | Error |
113
+ |-------|-----------|-------|
114
+ | `paintWear` | `[0.0, 1.0]` | `RangeError` |
115
+ | `customName` | max 100 characters | `RangeError` |
116
+
117
+ ---
118
+
101
119
  ## How the format works
102
120
 
103
121
  Three URL formats are handled:
package/package.json CHANGED
@@ -1,17 +1,27 @@
1
1
  {
2
2
  "name": "@vlydev/cs2-masked-inspect",
3
- "version": "1.0.0",
3
+ "version": "1.0.7",
4
4
  "description": "Offline decoder/encoder for CS2 masked inspect URLs — pure JavaScript, no dependencies",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test tests/inspect_link.test.js"
8
8
  },
9
- "keywords": ["cs2", "csgo", "inspect", "protobuf", "steam"],
9
+ "keywords": [
10
+ "cs2",
11
+ "csgo",
12
+ "inspect",
13
+ "protobuf",
14
+ "steam"
15
+ ],
10
16
  "author": {
11
17
  "name": "VlyDev",
12
18
  "email": "vladdnepr1989@gmail.com",
13
19
  "url": "https://github.com/vlydev"
14
20
  },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/vlydev/cs2-masked-inspect-js"
24
+ },
15
25
  "license": "MIT",
16
26
  "engines": {
17
27
  "node": ">=18.0.0"
@@ -102,11 +102,14 @@ function extractHex(input) {
102
102
  const mh = stripped.match(HYBRID_URL_RE);
103
103
  if (mh && /[A-Fa-f]/.test(mh[1])) return mh[1];
104
104
 
105
- // Classic/market URL: A<hex> preceded by %20, space, or + (A is a prefix marker, not hex)
105
+ // Classic/market URL: A<hex> preceded by %20, space, or + (A is a prefix marker, not hex).
106
+ // If stripping A yields odd-length hex, A is actually the first byte of the payload —
107
+ // fall through to the pure-masked check below which captures it with A included.
106
108
  const m = stripped.match(INSPECT_URL_RE);
107
- if (m) return m[1];
109
+ if (m && m[1].length % 2 === 0) return m[1];
108
110
 
109
- // Pure masked format: csgo_econ_action_preview%20<hexblob> (no S/A/M prefix)
111
+ // Pure masked format: csgo_econ_action_preview%20<hexblob> (no S/A/M prefix).
112
+ // Also handles payloads whose first hex character happens to be A.
110
113
  const mm = stripped.match(/csgo_econ_action_preview(?:%20|\s|\+)([0-9A-Fa-f]{10,})$/i);
111
114
  if (mm) return mm[1];
112
115
 
@@ -131,6 +134,7 @@ function encodeSticker(s) {
131
134
  if (s.offsetY !== null) w.writeFloat32Fixed(8, s.offsetY);
132
135
  if (s.offsetZ !== null) w.writeFloat32Fixed(9, s.offsetZ);
133
136
  w.writeUint32(10, s.pattern);
137
+ if (s.highlightReel != null) w.writeUint32(11, s.highlightReel);
134
138
  return w.toBytes();
135
139
  }
136
140
 
@@ -150,7 +154,8 @@ function decodeSticker(data) {
150
154
  case 7: s.offsetX = f.value.readFloatLE(0); break;
151
155
  case 8: s.offsetY = f.value.readFloatLE(0); break;
152
156
  case 9: s.offsetZ = f.value.readFloatLE(0); break;
153
- case 10: s.pattern = Number(f.value); break;
157
+ case 10: s.pattern = Number(f.value); break;
158
+ case 11: s.highlightReel = Number(f.value); break;
154
159
  default: break;
155
160
  }
156
161
  }
@@ -173,8 +178,9 @@ function encodeItem(item) {
173
178
  w.writeUint32(6, item.quality);
174
179
 
175
180
  // paintwear: float32 reinterpreted as uint32 varint
176
- const pwUint32 = float32ToUint32(item.paintWear);
177
- w.writeUint32(7, pwUint32);
181
+ if (item.paintWear != null) {
182
+ w.writeUint32(7, float32ToUint32(item.paintWear));
183
+ }
178
184
 
179
185
  w.writeUint32(8, item.paintSeed);
180
186
  w.writeUint32(9, item.killEaterScoreType);
@@ -249,6 +255,16 @@ class InspectLink {
249
255
  * @returns {string} uppercase hex string
250
256
  */
251
257
  static serialize(data) {
258
+ if (data.paintWear != null && (data.paintWear < 0.0 || data.paintWear > 1.0)) {
259
+ throw new RangeError(
260
+ `paintwear must be in [0.0, 1.0], got ${data.paintWear}`,
261
+ );
262
+ }
263
+ if (data.customName != null && data.customName.length > 100) {
264
+ throw new RangeError(
265
+ `customname must not exceed 100 characters, got ${data.customName.length}`,
266
+ );
267
+ }
252
268
  const protoBytes = encodeItem(data);
253
269
  const buffer = Buffer.concat([Buffer.from([0x00]), protoBytes]);
254
270
  const checksum = computeChecksum(buffer, protoBytes.length);
@@ -291,6 +307,11 @@ class InspectLink {
291
307
  */
292
308
  static deserialize(input) {
293
309
  const hex = extractHex(input);
310
+ if (hex.length > 4096) {
311
+ throw new RangeError(
312
+ `Payload too long (max 4096 hex chars): "${input.slice(0, 64)}..."`,
313
+ );
314
+ }
294
315
  const raw = Buffer.from(hex, 'hex');
295
316
 
296
317
  if (raw.length < 6) {
@@ -21,7 +21,7 @@ class ItemPreviewData {
21
21
  * @param {number} [opts.paintIndex=0]
22
22
  * @param {number} [opts.rarity=0]
23
23
  * @param {number} [opts.quality=0]
24
- * @param {number} [opts.paintWear=0.0]
24
+ * @param {number|null} [opts.paintWear=null]
25
25
  * @param {number} [opts.paintSeed=0]
26
26
  * @param {number} [opts.killEaterScoreType=0]
27
27
  * @param {number} [opts.killEaterValue=0]
@@ -43,7 +43,7 @@ class ItemPreviewData {
43
43
  paintIndex = 0,
44
44
  rarity = 0,
45
45
  quality = 0,
46
- paintWear = 0.0,
46
+ paintWear = null,
47
47
  paintSeed = 0,
48
48
  killEaterScoreType = 0,
49
49
  killEaterValue = 0,
package/src/Sticker.js CHANGED
@@ -19,6 +19,7 @@ class Sticker {
19
19
  * @param {number|null} [opts.offsetY=null]
20
20
  * @param {number|null} [opts.offsetZ=null]
21
21
  * @param {number} [opts.pattern=0]
22
+ * @param {number|null} [opts.highlightReel=null]
22
23
  */
23
24
  constructor({
24
25
  slot = 0,
@@ -31,6 +32,7 @@ class Sticker {
31
32
  offsetY = null,
32
33
  offsetZ = null,
33
34
  pattern = 0,
35
+ highlightReel = null,
34
36
  } = {}) {
35
37
  this.slot = slot;
36
38
  this.stickerId = stickerId;
@@ -42,6 +44,7 @@ class Sticker {
42
44
  this.offsetY = offsetY;
43
45
  this.offsetZ = offsetZ;
44
46
  this.pattern = pattern;
47
+ this.highlightReel = highlightReel;
45
48
  }
46
49
  }
47
50
 
@@ -94,8 +94,12 @@ class ProtoReader {
94
94
  */
95
95
  readAllFields() {
96
96
  const fields = [];
97
+ let fieldCount = 0;
97
98
 
98
99
  while (this.remaining() > 0) {
100
+ if (++fieldCount > 100) {
101
+ throw new RangeError('Protobuf field count exceeds limit of 100');
102
+ }
99
103
  const [fieldNum, wireType] = this.readTag();
100
104
 
101
105
  let value;
@@ -359,3 +359,91 @@ describe('checksum', () => {
359
359
  assert.equal(InspectLink.serialize(data), TOOL_HEX);
360
360
  });
361
361
  });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Defensive validation tests
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('deserialize — payload too long', () => {
368
+ test('throws RangeError for hex payload exceeding 4096 chars (4098 chars)', () => {
369
+ const longHex = '00'.repeat(2049); // 4098 hex chars
370
+ assert.throws(() => InspectLink.deserialize(longHex), RangeError);
371
+ });
372
+ });
373
+
374
+ describe('serialize — paintwear validation', () => {
375
+ test('throws RangeError when paintwear > 1.0', () => {
376
+ const data = new ItemPreviewData({ paintWear: 1.1 });
377
+ assert.throws(() => InspectLink.serialize(data), RangeError);
378
+ });
379
+
380
+ test('throws RangeError when paintwear < 0.0', () => {
381
+ const data = new ItemPreviewData({ paintWear: -0.1 });
382
+ assert.throws(() => InspectLink.serialize(data), RangeError);
383
+ });
384
+
385
+ test('does not throw when paintwear = 0.0 (boundary)', () => {
386
+ const data = new ItemPreviewData({ paintWear: 0.0 });
387
+ assert.doesNotThrow(() => InspectLink.serialize(data));
388
+ });
389
+
390
+ test('does not throw when paintwear = 1.0 (boundary)', () => {
391
+ const data = new ItemPreviewData({ paintWear: 1.0 });
392
+ assert.doesNotThrow(() => InspectLink.serialize(data));
393
+ });
394
+ });
395
+
396
+ describe('serialize — customname validation', () => {
397
+ test('throws RangeError when customName is 101 characters', () => {
398
+ const data = new ItemPreviewData({ customName: 'a'.repeat(101) });
399
+ assert.throws(() => InspectLink.serialize(data), RangeError);
400
+ });
401
+
402
+ test('does not throw when customName is exactly 100 characters', () => {
403
+ const data = new ItemPreviewData({ customName: 'a'.repeat(100) });
404
+ assert.doesNotThrow(() => InspectLink.serialize(data));
405
+ });
406
+ });
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // CSFloat/gen.test.ts vectors
410
+ // ---------------------------------------------------------------------------
411
+
412
+ const CSFLOAT_A = '00180720DA03280638FBEE88F90340B2026BC03C96';
413
+ const CSFLOAT_B = '00180720C80A280638A4E1F5FB03409A0562040800104C62040801104C62040802104C62040803104C6D4F5E30';
414
+ const CSFLOAT_C = 'A2B2A2BA69A882A28AA192AECAA2D2B700A3A5AAA2B286FA7BA0D684BE72';
415
+
416
+ describe('CSFloat test vectors', () => {
417
+ test('VectorA: defindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).defIndex, 7));
418
+ test('VectorA: paintindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).paintIndex, 474));
419
+ test('VectorA: paintseed', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).paintSeed, 306));
420
+ test('VectorA: rarity', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).rarity, 6));
421
+ test('VectorA: paintwear not null', () => assert.notEqual(InspectLink.deserialize(CSFLOAT_A).paintWear, null));
422
+ test('VectorA: paintwear approx', () => assert.ok(Math.abs(InspectLink.deserialize(CSFLOAT_A).paintWear - 0.6337) < 0.001));
423
+
424
+ test('VectorB: 4 stickers', () => assert.equal(InspectLink.deserialize(CSFLOAT_B).stickers.length, 4));
425
+ test('VectorB: sticker ids all 76', () => {
426
+ InspectLink.deserialize(CSFLOAT_B).stickers.forEach(s => assert.equal(s.stickerId, 76));
427
+ });
428
+ test('VectorB: paintindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_B).paintIndex, 1352));
429
+ test('VectorB: paintwear approx 0.99', () => assert.ok(Math.abs(InspectLink.deserialize(CSFLOAT_B).paintWear - 0.99) < 0.01));
430
+
431
+ test('VectorC: defindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).defIndex, 1355));
432
+ test('VectorC: quality', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).quality, 12));
433
+ test('VectorC: keychain count', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).keychains.length, 1));
434
+ test('VectorC: keychain highlightReel', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).keychains[0].highlightReel, 345));
435
+ test('VectorC: no paintwear', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).paintWear, null));
436
+ });
437
+
438
+ describe('Roundtrip: highlight_reel and nullable paintWear', () => {
439
+ test('highlightReel roundtrip', () => {
440
+ const data = new ItemPreviewData({ defIndex: 7, keychains: [new Sticker({ slot: 0, stickerId: 36, highlightReel: 345 })] });
441
+ const result = InspectLink.deserialize(InspectLink.serialize(data));
442
+ assert.equal(result.keychains[0].highlightReel, 345);
443
+ });
444
+ test('null paintWear roundtrip', () => {
445
+ const data = new ItemPreviewData({ defIndex: 7, paintWear: null });
446
+ const result = InspectLink.deserialize(InspectLink.serialize(data));
447
+ assert.equal(result.paintWear, null);
448
+ });
449
+ });