leerness 1.36.183 → 1.36.185

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/lib/io.js CHANGED
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const cp = require('child_process');
10
+ const crypto = require('crypto');
10
11
 
11
12
  // 1.10.2 (UR-0146): quiet 모드 — 사람용 출력(log/ok/warn) 억제. init --json 등에서 큰 핸들러의 다수 log 를 비침투적으로 묵음 → 순수 JSON 1개만 출력.
12
13
  // fail/failJson(오류)은 묵음 대상 아님(에러는 항상 노출). setQuiet 로 토글, 호출부 finally 에서 반드시 복구.
@@ -175,6 +176,10 @@ function _patchFsForDryRun() {
175
176
  }
176
177
  }
177
178
  function setDryRunGuard(on) { _dryGuard = !!on; if (on) _patchFsForDryRun(); }
179
+ // File-descriptor based writers cannot be intercepted by the fs path mutator
180
+ // wrappers. Call this before opening a descriptor so --dry-run retains its
181
+ // process-wide zero-persistent-write contract.
182
+ function assertWriteAllowed(p) { _dryCheck(p); }
178
183
  // ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
179
184
  // `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
180
185
  // (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
@@ -484,4 +489,150 @@ function _renameWithRetry(from, to) {
484
489
  function append(p, s) { _dryCheck(p); mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
485
490
  function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
486
491
 
487
- module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, writeBufferIfUnchanged, append, rel, setDryRunGuard, mkdirpRaw };
492
+ function _hardLinkIdentityEqual(a, b) {
493
+ return !!(a && b)
494
+ && String(a.dev) === String(b.dev)
495
+ && String(a.ino) === String(b.ino);
496
+ }
497
+
498
+ function _lstatState(p) {
499
+ try { return { exists: true, stat: fs.lstatSync(p), error: null }; }
500
+ catch (error) {
501
+ if (error && error.code === 'ENOENT') return { exists: false, stat: null, error: null };
502
+ return { exists: null, stat: null, error };
503
+ }
504
+ }
505
+
506
+ // A failed alias detach means the caller must not observe success. Remove the
507
+ // just-installed canonical name only when both names still identify the exact
508
+ // committed inode and its bytes are unchanged. Existing-file callers can then
509
+ // restore their captured predecessor; create callers leave the canonical path
510
+ // absent. A random quarantine rename is the last exact-path fallback when a
511
+ // platform refuses both unlink spellings.
512
+ function _rollbackCommittedHardLink(alias, live, expected) {
513
+ const liveState = _lstatState(live);
514
+ if (liveState.exists === false) return { ok: true, canonicalState: 'absent', artifact: null };
515
+ const aliasState = _lstatState(alias);
516
+ if (liveState.exists !== true || aliasState.exists !== true
517
+ || liveState.stat.isSymbolicLink() || !liveState.stat.isFile()
518
+ || aliasState.stat.isSymbolicLink() || !aliasState.stat.isFile()
519
+ || !_hardLinkIdentityEqual(liveState.stat, aliasState.stat)) {
520
+ return { ok: false, canonicalState: 'unknown', artifact: null, error: liveState.error || aliasState.error || null };
521
+ }
522
+ if (expected) {
523
+ try {
524
+ if (!fs.readFileSync(live).equals(expected) || !fs.readFileSync(alias).equals(expected)) {
525
+ return { ok: false, canonicalState: 'changed', artifact: null, error: null };
526
+ }
527
+ } catch (error) {
528
+ return { ok: false, canonicalState: 'unknown', artifact: null, error };
529
+ }
530
+ }
531
+
532
+ let removalError = null;
533
+ try { fs.unlinkSync(live); }
534
+ catch (error) { removalError = error; }
535
+ if (_lstatState(live).exists === true) {
536
+ try { fs.rmSync(live, { force: true }); }
537
+ catch (error) { removalError = removalError || error; }
538
+ }
539
+ if (_lstatState(live).exists === true) {
540
+ const quarantine = `${live}.rollback-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
541
+ try {
542
+ fs.renameSync(live, quarantine);
543
+ try { fs.unlinkSync(quarantine); }
544
+ catch {
545
+ try { fs.rmSync(quarantine, { force: true }); } catch {}
546
+ }
547
+ const postRename = _lstatState(live);
548
+ return postRename.exists === false
549
+ ? { ok: true, canonicalState: 'absent', artifact: _lstatState(quarantine).exists === true ? quarantine : null }
550
+ : { ok: false, canonicalState: 'present', artifact: quarantine, error: removalError };
551
+ } catch (error) {
552
+ removalError = removalError || error;
553
+ }
554
+ }
555
+ const after = _lstatState(live);
556
+ if (after.exists === false) return { ok: true, canonicalState: 'absent', artifact: null };
557
+ if (after.exists === true && !after.stat.isSymbolicLink() && after.stat.isFile()
558
+ && Number(after.stat.nlink) === 1
559
+ && (!expected || fs.readFileSync(live).equals(expected))) {
560
+ return { ok: true, canonicalState: 'detached', artifact: null };
561
+ }
562
+ return { ok: false, canonicalState: after.exists === true ? 'present' : 'unknown', artifact: null, error: after.error || removalError };
563
+ }
564
+
565
+ // An exclusive hard-link install is useful when rename-without-replace is not
566
+ // available, but the live inode must not depend on successfully unlinking the
567
+ // prepared alias. If unlink fails after commit, atomically replace that alias
568
+ // with a different inode; this drops the live inode back to nlink=1 even when
569
+ // the harmless replacement artifact cannot itself be removed.
570
+ function detachCommittedHardLink(aliasPath, livePath, expectedBytes) {
571
+ const alias = path.resolve(aliasPath);
572
+ const live = path.resolve(livePath);
573
+ let firstCleanupError = null;
574
+ try {
575
+ fs.unlinkSync(alias);
576
+ } catch (error) {
577
+ firstCleanupError = error;
578
+ // Some transient/platform wrappers fail unlink but permit the equivalent
579
+ // exact-path removal. This is only the random prepared alias, never user data.
580
+ try { fs.rmSync(alias, { force: true }); } catch {}
581
+ if (fs.existsSync(alias)) {
582
+ const detacher = `${alias}.detach-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
583
+ let detacherExists = false;
584
+ try {
585
+ fs.writeFileSync(detacher, Buffer.alloc(0), { flag: 'wx', mode: 0o600 });
586
+ detacherExists = true;
587
+ // Replacing the alias unlinks it from the committed inode as part of
588
+ // rename, while leaving the live pathname and its bytes untouched.
589
+ fs.renameSync(detacher, alias);
590
+ detacherExists = false;
591
+ } catch (detachError) {
592
+ try { if (detacherExists) fs.rmSync(detacher, { force: true }); } catch {}
593
+ const expected = Buffer.isBuffer(expectedBytes) ? expectedBytes : null;
594
+ const rollback = _rollbackCommittedHardLink(alias, live, expected);
595
+ // Another actor may have detached the alias between our failed rename
596
+ // and rollback probe. A valid nlink=1 live file already satisfies the
597
+ // commit contract, so this narrow state remains a successful recovery.
598
+ if (rollback.ok && rollback.canonicalState === 'detached') {
599
+ return { cleanupRecovered: true, cleanupArtifact: fs.existsSync(alias) ? alias : null };
600
+ }
601
+ const failure = new Error(`committed hard-link alias could not be detached: ${alias}`);
602
+ failure.code = 'E_HARDLINK_DETACH_FAILED';
603
+ failure.cleanupError = (detachError && detachError.message) || String(detachError);
604
+ failure.canonicalRolledBack = rollback.ok && rollback.canonicalState === 'absent';
605
+ failure.rollbackState = rollback.canonicalState;
606
+ failure.rollbackArtifact = rollback.artifact || null;
607
+ failure.rollbackError = rollback.error && (rollback.error.message || String(rollback.error));
608
+ failure.cause = firstCleanupError;
609
+ throw failure;
610
+ }
611
+ // The path now names an unrelated empty inode. Failure to remove this
612
+ // cleanup artifact is non-fatal and cannot poison the committed file.
613
+ try { fs.rmSync(alias, { force: true }); } catch {}
614
+ }
615
+ }
616
+ let stat;
617
+ try { stat = fs.lstatSync(live); }
618
+ catch (error) {
619
+ const failure = new Error(`committed file disappeared while detaching its prepared alias: ${live}`);
620
+ failure.code = 'E_HARDLINK_DETACH_FAILED';
621
+ failure.cause = error;
622
+ throw failure;
623
+ }
624
+ const expected = Buffer.isBuffer(expectedBytes) ? expectedBytes : null;
625
+ if (stat.isSymbolicLink() || !stat.isFile() || Number(stat.nlink) !== 1
626
+ || (expected && !fs.readFileSync(live).equals(expected))) {
627
+ const failure = new Error(`committed file failed post-detach validation: ${live}`);
628
+ failure.code = 'E_HARDLINK_DETACH_FAILED';
629
+ failure.cleanupError = firstCleanupError && firstCleanupError.message;
630
+ throw failure;
631
+ }
632
+ return {
633
+ cleanupRecovered: !!firstCleanupError,
634
+ cleanupArtifact: fs.existsSync(alias) ? alias : null,
635
+ };
636
+ }
637
+
638
+ module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, writeBufferIfUnchanged, append, rel, setDryRunGuard, assertWriteAllowed, mkdirpRaw, detachCommittedHardLink };