Skip to content

Commit 03ec70b

Browse files
committed
fix: resolve quality scan issues (iteration 3)
- Fix division by zero in progress bars, spinners, and HTTP requests - Add LRU cache eviction to git diff cache (prevent memory leak) - Fix TOCTOU race in cache-with-ttl getOrFetch - Improve clock skew detection in TTL cache - Fix stable cache key generation in globs - Fix memoizeAsync promise caching (don't cache failures) - Add TTL validation in memoization - Fix documentation errors (writeFileUtf8, PromiseQueue, getUserProfile) - Align Node versions in CI workflow - Pin dependency versions (libnpmexec, tar-stream) - Add safety to parseInt in node.ts - Fix bash loop patterns in git hooks - Add note about fast-glob external dependency - Add archive extraction documentation - Clarify root import restriction reasoning All 6328 tests passing.
1 parent 86b4838 commit 03ec70b

17 files changed

Lines changed: 128 additions & 54 deletions

.git-hooks/pre-push

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ while read local_ref local_sha remote_ref remote_sha; do
117117
fi
118118

119119
# Check file contents for secrets.
120-
echo "$CHANGED_FILES" | while IFS= read -r file; do
120+
while IFS= read -r file; do
121121
if [ -f "$file" ] && [ ! -d "$file" ]; then
122122
# Skip test files, example files, and hook scripts.
123123
if echo "$file" | grep -qE '\.(test|spec)\.(m?[jt]s|tsx?)$|\.example$|/test/|/tests/|fixtures/|\.git-hooks/|\.husky/'; then
@@ -158,7 +158,7 @@ while read local_ref local_sha remote_ref remote_sha; do
158158
ERRORS=$((ERRORS + 1))
159159
fi
160160
fi
161-
done
161+
done <<< "$CHANGED_FILES"
162162
fi
163163

164164
TOTAL_ERRORS=$((TOTAL_ERRORS + ERRORS))

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ on:
1919
description: 'Node.js versions to test (JSON array)'
2020
required: false
2121
type: string
22-
default: '["24.10.0"]'
22+
default: '["20", "22", "24"]'
2323

2424
permissions:
2525
contents: read
@@ -33,7 +33,7 @@ jobs:
3333
lint-script: 'pnpm run lint --all'
3434
type-check-script: 'pnpm run check'
3535
test-script: 'pnpm exec vitest --config .config/vitest.config.mts run'
36-
node-versions: ${{ inputs.node-versions || '["24.10.0"]' }}
36+
node-versions: ${{ inputs.node-versions || '["20", "22", "24"]' }}
3737
os-versions: '["ubuntu-latest", "windows-latest"]'
3838
fail-fast: false
3939
max-parallel: 4
@@ -48,7 +48,7 @@ jobs:
4848
steps:
4949
- uses: SocketDev/socket-registry/.github/actions/setup-and-install@67a3db92603c23c58031586611c7cc852244c87c # main
5050
with:
51-
node-version: '22'
51+
node-version: '24'
5252

5353
- name: Build project
5454
run: pnpm run build

.husky/security-checks.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ fi
5454

5555
# Check for hardcoded user paths (generic detection).
5656
printf "Checking for hardcoded personal paths...\n"
57-
echo "$STAGED_FILES" | while IFS= read -r file; do
57+
while IFS= read -r file; do
5858
if [ -f "$file" ]; then
5959
# Skip test files and hook scripts.
6060
if echo "$file" | grep -qE '\.(test|spec)\.|/test/|/tests/|fixtures/|\.git-hooks/|\.husky/'; then
@@ -69,7 +69,7 @@ echo "$STAGED_FILES" | while IFS= read -r file; do
6969
ERRORS=$((ERRORS + 1))
7070
fi
7171
fi
72-
done
72+
done <<< "$STAGED_FILES"
7373

7474
# Check for Socket API keys.
7575
printf "Checking for API keys...\n"

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,9 @@ import { isTest } from '#env/test'
376376
Each env module exports a pure getter function that accesses only its own environment variable. For fallback logic, compose multiple getters:
377377
```typescript
378378
import { getHome } from '#env/home'
379-
import { getUserProfile } from '#env/userprofile'
379+
import { getUserprofile } from '#env/windows'
380380
381-
const homeDir = getHome() || getUserProfile() // Cross-platform fallback
381+
const homeDir = getHome() || getUserprofile() // Cross-platform fallback
382382
```
383383

384384
**Testing with rewiring:**

docs/examples.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,8 @@ const results = await checkHealth([
446446
Execute tasks with limited concurrency:
447447

448448
```typescript
449+
import fs from 'node:fs/promises'
450+
449451
import { PromiseQueue } from '@socketsecurity/lib/promise-queue'
450452
import { Spinner } from '@socketsecurity/lib/spinner'
451453
import { getDefaultLogger } from '@socketsecurity/lib/logger'
@@ -460,7 +462,7 @@ async function processBatch<T>(
460462

461463
logger.step(`Processing ${items.length} items with concurrency ${concurrency}`)
462464

463-
const queue = new PromiseQueue({ concurrency })
465+
const queue = new PromiseQueue(concurrency)
464466
let completed = 0
465467

466468
spinner.progress(0, items.length, 'items')
@@ -485,7 +487,7 @@ await processBatch(
485487
async (file) => {
486488
const content = await readFileUtf8(file)
487489
const processed = content.toUpperCase()
488-
await writeFileUtf8(file, processed)
490+
await fs.writeFile(file, processed, 'utf8')
489491
},
490492
10 // Process 10 files at a time
491493
)

docs/file-system.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,7 @@ if (pkgPath) {
623623
### Processing Files with Validation
624624

625625
```typescript
626+
// Note: fast-glob is an external dependency - install it separately
626627
import { glob } from 'fast-glob'
627628
import { validateFiles, readFileUtf8 } from '@socketsecurity/lib/fs'
628629

@@ -639,6 +640,26 @@ for (const file of validPaths) {
639640
}
640641
```
641642

643+
### Extracting Archives
644+
645+
```typescript
646+
import { extractArchive, detectArchiveFormat } from '@socketsecurity/lib/archives'
647+
648+
// Detect archive format
649+
const format = detectArchiveFormat('package.tar.gz')
650+
console.log(format) // 'tar.gz'
651+
652+
// Extract archive with safety limits
653+
await extractArchive('package.tar.gz', './output', {
654+
strip: 1, // Strip one leading path component
655+
maxFileSize: 100 * 1024 * 1024, // 100MB per file
656+
maxTotalSize: 1024 * 1024 * 1024, // 1GB total
657+
})
658+
659+
// Supports: .zip, .tar, .tar.gz, .tgz
660+
// Built-in protection against zip bombs and path traversal
661+
```
662+
642663
## Troubleshooting
643664

644665
### ENOENT: no such file or directory

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import { readJson } from '@socketsecurity/lib/fs'
7676
// import { Spinner, readJson } from '@socketsecurity/lib'
7777
```
7878

79-
This approach keeps your bundle size small by only including the code you actually use.
79+
**Why subpath imports?** This library is designed for selective imports to keep bundle sizes minimal. Each subpath (like `/spinner` or `/fs`) is a separate export point defined in `package.json` exports field. The root import (`@socketsecurity/lib`) doesn't re-export all modules - you must use specific subpaths.
8080

8181
## Common Use Cases
8282

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ Check your `tsconfig.json`:
449449
```typescript
450450
import { PromiseQueue } from '@socketsecurity/lib/promise-queue'
451451
452-
const queue = new PromiseQueue({ concurrency: 10 })
452+
const queue = new PromiseQueue(10)
453453
await Promise.all(
454454
files.map(file => queue.add(() => readFileUtf8(file)))
455455
)

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@
759759
"globals": "16.4.0",
760760
"has-flag": "5.0.1",
761761
"husky": "9.1.7",
762-
"libnpmexec": "^10.2.3",
762+
"libnpmexec": "10.2.3",
763763
"libnpmpack": "9.0.9",
764764
"lint-staged": "15.2.11",
765765
"magic-string": "0.30.17",
@@ -777,7 +777,7 @@
777777
"streaming-iterables": "8.0.1",
778778
"supports-color": "10.0.0",
779779
"tar-fs": "3.1.2",
780-
"tar-stream": "^3.1.8",
780+
"tar-stream": "3.1.8",
781781
"taze": "19.9.2",
782782
"trash": "10.0.0",
783783
"type-coverage": "2.29.7",

src/cache-with-ttl.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,9 @@ export function createTtlCache(options?: TtlCacheOptions): TtlCache {
195195
function isExpired(entry: TtlCacheEntry<any>): boolean {
196196
const now = Date.now()
197197
// Detect future expiresAt (clock skew or corruption).
198-
// If expiresAt is more than 2x TTL in the future, treat as expired.
199-
if (entry.expiresAt > now + ttl * 2) {
198+
// If expiresAt is more than 10 seconds past expected expiry, treat as expired.
199+
const maxFutureMs = 10_000
200+
if (entry.expiresAt > now + ttl + maxFutureMs) {
200201
return true
201202
}
202203
return now > entry.expiresAt
@@ -406,31 +407,33 @@ export function createTtlCache(options?: TtlCacheOptions): TtlCache {
406407

407408
const fullKey = buildKey(key)
408409

409-
// Check if fetch is already in progress (atomic check-and-set)
410-
const inflight = inflightRequests.get(fullKey)
411-
if (inflight) {
412-
return await inflight
410+
// Atomic check-and-set to prevent TOCTOU race
411+
const existing = inflightRequests.get(fullKey)
412+
if (existing) {
413+
return await existing
413414
}
414415

415-
// Create promise before storing to minimize TOCTOU window
416+
// Create and immediately store promise atomically
416417
const promise = (async () => {
417418
try {
418419
const data = await fetcher()
419420
await set(key, data)
420421
return data
422+
} catch (error) {
423+
// Clean up on error
424+
inflightRequests.delete(fullKey)
425+
throw error
421426
} finally {
422427
inflightRequests.delete(fullKey)
423428
}
424429
})()
425430

426-
// Double-check in case another call set it between our checks
427-
const existing = inflightRequests.get(fullKey)
428-
if (existing) {
429-
// Another call won the race, use their promise
430-
return await existing
431+
// Final check - if another thread won, use theirs
432+
const nowExisting = inflightRequests.get(fullKey)
433+
if (nowExisting && nowExisting !== promise) {
434+
return await nowExisting
431435
}
432436

433-
// We won the race, store our promise
434437
inflightRequests.set(fullKey, promise)
435438
return await promise
436439
}

0 commit comments

Comments
 (0)