From 2b982ef534c438c14c53db466da4e5b7db881c5e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 5 Jul 2026 20:59:14 +0800 Subject: [PATCH 1/4] Add diffWithCovers and patchSingleStream native APIs - diffWithCovers(old, new, covers, options): create standard hdiffpatch payloads from caller-provided cover lines, with replace/merge/ native-coalesce modes and optional debugCovers introspection - patchSingleStream: file-level apply for single-compressed diffs produced by diff()/diffWithCovers() - Point HDiffPatch submodule at reactnativecn fork (over https) for the cover-listener capacity change; migrate module registration out of the namespace and guard diff-declared sizes against size_t truncation Co-Authored-By: Claude Fable 5 --- .gitmodules | 2 +- HDiffPatch | 2 +- index.d.ts | 51 ++++++++ src/hdiff.cpp | 312 +++++++++++++++++++++++++++++++++++++++++++++++-- src/hdiff.h | 26 +++++ src/hpatch.cpp | 81 ++++++++++++- src/hpatch.h | 1 + src/main.cc | 286 ++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 745 insertions(+), 16 deletions(-) diff --git a/.gitmodules b/.gitmodules index 3f3fd4d..8a7e52b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "HDiffPatch"] path = HDiffPatch - url = https://github.com/sisong/HDiffPatch.git + url = https://github.com/reactnativecn/HDiffPatch.git [submodule "lzma"] path = lzma url = https://github.com/sisong/lzma.git diff --git a/HDiffPatch b/HDiffPatch index 2458033..4a7eb78 160000 --- a/HDiffPatch +++ b/HDiffPatch @@ -1 +1 @@ -Subproject commit 245803345a789e7675a32dc22d61a4f8847cada3 +Subproject commit 4a7eb78aee3bd5acbe6fe55621391685648030d5 diff --git a/index.d.ts b/index.d.ts index ece728f..d3f19dd 100644 --- a/index.d.ts +++ b/index.d.ts @@ -4,10 +4,35 @@ export type BinaryLike = Buffer | ArrayBufferView; export type DiffCallback = (err: Error | null, result?: Buffer) => void; export type StreamCallback = (err: Error | null, outPath?: string) => void; +export type HpatchCover = { + oldPos: number | string | bigint; + newPos: number | string | bigint; + len: number | string | bigint; +}; +export type DiffWithCoversResult = { + diff: Buffer; + usedCovers: boolean; + requestedCoverCount: number; + nativeCoverCapacity: number; + finalCoverCount: number; + coverMode: 'replace' | 'merge' | 'native-coalesce'; + nativeCovers?: HpatchCover[]; + finalCovers?: HpatchCover[]; +}; +export type DiffWithCoversOptions = { + mode?: 'replace' | 'merge' | 'native-coalesce' | 'native_coalesce'; + debugCovers?: boolean; +}; export interface NativeAddon { diff(oldBuf: BinaryLike, newBuf: BinaryLike): Buffer; diff(oldBuf: BinaryLike, newBuf: BinaryLike, cb: DiffCallback): void; + diffWithCovers( + oldBuf: BinaryLike, + newBuf: BinaryLike, + covers: HpatchCover[], + options?: DiffWithCoversOptions + ): DiffWithCoversResult; patch(oldBuf: BinaryLike, diffBuf: BinaryLike): Buffer; patch(oldBuf: BinaryLike, diffBuf: BinaryLike, cb: DiffCallback): void; diffStream(oldPath: string, newPath: string, outDiffPath: string): string; @@ -24,6 +49,13 @@ export interface NativeAddon { outNewPath: string, cb: StreamCallback ): void; + patchSingleStream(oldPath: string, diffPath: string, outNewPath: string): string; + patchSingleStream( + oldPath: string, + diffPath: string, + outNewPath: string, + cb: StreamCallback + ): void; } export const native: NativeAddon; @@ -34,6 +66,12 @@ export function diff( newBuf: BinaryLike, cb: DiffCallback ): void; +export function diffWithCovers( + oldBuf: BinaryLike, + newBuf: BinaryLike, + covers: HpatchCover[], + options?: DiffWithCoversOptions +): DiffWithCoversResult; export function patch(oldBuf: BinaryLike, diffBuf: BinaryLike): Buffer; export function patch( @@ -65,13 +103,26 @@ export function patchStream( outNewPath: string, cb: StreamCallback ): void; +export function patchSingleStream( + oldPath: string, + diffPath: string, + outNewPath: string +): string; +export function patchSingleStream( + oldPath: string, + diffPath: string, + outNewPath: string, + cb: StreamCallback +): void; declare const hdiffpatch: { native: NativeAddon; diff: typeof diff; + diffWithCovers: typeof diffWithCovers; patch: typeof patch; diffStream: typeof diffStream; patchStream: typeof patchStream; + patchSingleStream: typeof patchSingleStream; }; export default hdiffpatch; diff --git a/src/hdiff.cpp b/src/hdiff.cpp index 59f3123..7f9d10f 100644 --- a/src/hdiff.cpp +++ b/src/hdiff.cpp @@ -1,27 +1,272 @@ #include "hdiff.h" #include "../HDiffPatch/libHDiffPatch/HDiff/diff.h" #include "../HDiffPatch/file_for_patch.h" +#include +#include #include #define _CompressPlugin_lzma2 #define _IsNeedIncludeDefaultCompressHead 0 +#define IS_NOTICE_compress_canceled 0 #include "../lzma/C/Lzma2Dec.h" #include "../lzma/C/Lzma2Enc.h" #include "../lzma/C/MtCoder.h" #include "../HDiffPatch/compress_plugin_demo.h" #include "../HDiffPatch/decompress_plugin_demo.h" +namespace { + struct CoverLinesListener { + ICoverLinesListener base; + const HdiffCover* covers; + size_t requestedCoverCount; + size_t nativeCoverCapacity; + size_t finalCoverCount; + bool hasNativeCoverCapacity; + bool usedCovers; + HdiffCoverMode coverMode; + bool hasPreparedCovers; + std::vector nativeCovers; + std::vector preparedCovers; + }; + + hpatch_TCover make_cover(uint64_t oldPos, uint64_t newPos, uint64_t length) { + hpatch_TCover cover; + cover.oldPos = static_cast(oldPos); + cover.newPos = static_cast(newPos); + cover.length = static_cast(length); + return cover; + } + + uint64_t cover_new_end(const hpatch_TCover& cover) { + return static_cast(cover.newPos) + static_cast(cover.length); + } + + uint64_t cover_old_end(const hpatch_TCover& cover) { + return static_cast(cover.oldPos) + static_cast(cover.length); + } + + HdiffCover to_hdiff_cover(const hpatch_TCover& cover) { + return { + static_cast(cover.oldPos), + static_cast(cover.newPos), + static_cast(cover.length), + }; + } + + std::vector to_hdiff_covers(const std::vector& covers) { + std::vector result; + result.reserve(covers.size()); + for (const hpatch_TCover& cover : covers) { + result.push_back(to_hdiff_cover(cover)); + } + return result; + } + + void push_external_slice(std::vector& out, + const HdiffCover& cover, + uint64_t sliceNewStart, + uint64_t sliceNewEnd) { + if (sliceNewEnd <= sliceNewStart) { + return; + } + const uint64_t delta = sliceNewStart - cover.newPos; + out.push_back(make_cover(cover.oldPos + delta, sliceNewStart, sliceNewEnd - sliceNewStart)); + } + + std::vector merge_covers(const hpatch_TCover* nativeCovers, + size_t nativeCoverCount, + const HdiffCover* externalCovers, + size_t externalCoverCount) { + std::vector merged; + merged.reserve(nativeCoverCount + externalCoverCount); + + for (size_t i = 0; i < nativeCoverCount; ++i) { + if (nativeCovers[i].length > 0) { + merged.push_back(nativeCovers[i]); + } + } + + size_t nativeIndex = 0; + for (size_t externalIndex = 0; externalIndex < externalCoverCount; ++externalIndex) { + const HdiffCover& external = externalCovers[externalIndex]; + const uint64_t externalEnd = external.newPos + external.length; + uint64_t cursor = external.newPos; + + while (nativeIndex < nativeCoverCount && cover_new_end(nativeCovers[nativeIndex]) <= cursor) { + ++nativeIndex; + } + + size_t scanIndex = nativeIndex; + while (scanIndex < nativeCoverCount && + static_cast(nativeCovers[scanIndex].newPos) < externalEnd) { + const uint64_t nativeStart = static_cast(nativeCovers[scanIndex].newPos); + const uint64_t nativeEnd = cover_new_end(nativeCovers[scanIndex]); + if (nativeStart > cursor) { + push_external_slice(merged, external, cursor, std::min(nativeStart, externalEnd)); + } + if (nativeEnd > cursor) { + cursor = nativeEnd; + if (cursor >= externalEnd) { + break; + } + } + ++scanIndex; + } + + push_external_slice(merged, external, cursor, externalEnd); + } + + std::sort(merged.begin(), merged.end(), [](const hpatch_TCover& left, const hpatch_TCover& right) { + if (left.newPos != right.newPos) { + return left.newPos < right.newPos; + } + return left.oldPos < right.oldPos; + }); + + return merged; + } + + std::vector coalesce_native_covers(const hpatch_TCover* nativeCovers, + size_t nativeCoverCount) { + const uint64_t maxGapBytes = 64; + std::vector coalesced; + coalesced.reserve(nativeCoverCount); + + for (size_t i = 0; i < nativeCoverCount; ++i) { + const hpatch_TCover& cover = nativeCovers[i]; + if (cover.length == 0) { + continue; + } + if (coalesced.empty()) { + coalesced.push_back(cover); + continue; + } + + hpatch_TCover& previous = coalesced.back(); + const uint64_t previousNewEnd = cover_new_end(previous); + const uint64_t previousOldEnd = cover_old_end(previous); + const uint64_t coverNewPos = static_cast(cover.newPos); + const uint64_t coverOldPos = static_cast(cover.oldPos); + if (coverNewPos < previousNewEnd || coverOldPos < previousOldEnd) { + coalesced.push_back(cover); + continue; + } + + const uint64_t newGap = coverNewPos - previousNewEnd; + const uint64_t oldGap = coverOldPos - previousOldEnd; + const int64_t previousDelta = + static_cast(previous.oldPos) - static_cast(previous.newPos); + const int64_t coverDelta = + static_cast(cover.oldPos) - static_cast(cover.newPos); + if (newGap <= maxGapBytes && oldGap == newGap && previousDelta == coverDelta) { + previous.length = static_cast(cover_new_end(cover) - previous.newPos); + continue; + } + + coalesced.push_back(cover); + } + + return coalesced; + } + + void prepare_listener_covers(CoverLinesListener* self, + hpatch_TCover* out_covers, + size_t currentCoverCapacity) { + if (self->hasPreparedCovers) { + return; + } + + if (self->coverMode == HdiffCoverMode::NativeCoalesce) { + self->preparedCovers = coalesce_native_covers(out_covers, currentCoverCapacity); + } else if (self->coverMode == HdiffCoverMode::Merge) { + self->preparedCovers = + merge_covers(out_covers, currentCoverCapacity, self->covers, self->requestedCoverCount); + } else { + self->preparedCovers.reserve(self->requestedCoverCount); + for (size_t i = 0; i < self->requestedCoverCount; ++i) { + self->preparedCovers.push_back(make_cover( + self->covers[i].oldPos, + self->covers[i].newPos, + self->covers[i].length)); + } + } + + self->finalCoverCount = self->preparedCovers.size(); + self->hasPreparedCovers = true; + } + + void coverLines(ICoverLinesListener* listener, hpatch_TCover* out_covers, size_t* coverCount, + hpatch_StreamPos_t* /*newSize*/, hpatch_StreamPos_t* /*oldSize*/) { + CoverLinesListener* self = reinterpret_cast(listener); + const size_t currentCoverCapacity = *coverCount; + if (!self->hasNativeCoverCapacity) { + self->nativeCoverCapacity = currentCoverCapacity; + self->nativeCovers.assign(out_covers, out_covers + currentCoverCapacity); + self->hasNativeCoverCapacity = true; + } + self->usedCovers = false; + prepare_listener_covers(self, out_covers, currentCoverCapacity); + + // HDiffPatch currently sizes out_covers to its native cover count and + // callers may use the first pass to request a larger cover buffer. + if (self->finalCoverCount > currentCoverCapacity) { + *coverCount = self->finalCoverCount; + return; + } + + for (size_t i = 0; i < self->finalCoverCount; ++i) { + out_covers[i] = self->preparedCovers[i]; + } + *coverCount = self->finalCoverCount; + self->usedCovers = true; + } + + void validate_covers(const HdiffCover* covers, size_t coverCount, size_t oldsize, size_t newsize) { + uint64_t previousNewEnd = 0; + const uint64_t oldSize64 = static_cast(oldsize); + const uint64_t newSize64 = static_cast(newsize); + + for (size_t i = 0; i < coverCount; ++i) { + const HdiffCover& cover = covers[i]; + if (cover.length == 0) { + throw std::runtime_error("Invalid cover: length must be greater than zero."); + } + if (cover.oldPos > oldSize64 || cover.length > oldSize64 - cover.oldPos) { + throw std::runtime_error("Invalid cover: old range is out of bounds."); + } + if (cover.newPos > newSize64 || cover.length > newSize64 - cover.newPos) { + throw std::runtime_error("Invalid cover: new range is out of bounds."); + } + if (cover.newPos < previousNewEnd) { + throw std::runtime_error("Invalid cover: new ranges must be sorted and non-overlapping."); + } + previousNewEnd = cover.newPos + cover.length; + + if (cover.oldPos > std::numeric_limits::max() || + cover.newPos > std::numeric_limits::max() || + cover.length > std::numeric_limits::max()) { + throw std::runtime_error("Invalid cover: range does not fit hpatch stream position."); + } + } + } + + void configure_lzma2(TCompressPlugin_lzma2& compressPlugin) { + const size_t myBestDictSize = (1 << 20) * 8; // 固定 8MB,与 v1.0.6 一致 + compressPlugin = lzma2CompressPlugin; + compressPlugin.compress_level = 9; + compressPlugin.dict_size = myBestDictSize; + compressPlugin.thread_num = 1; + } +} + void hdiff(const uint8_t* old, size_t oldsize, const uint8_t* _new, size_t newsize, std::vector& out_codeBuf) { const int myBestSingleMatchScore = 3; const size_t myBestStepMemSize = kDefaultStepMemSize; - const size_t myBestDictSize = (1 << 20) * 8; // 固定 8MB,与 v1.0.6 一致 hpatch_TDecompress* decompressPlugin = &lzma2DecompressPlugin; - TCompressPlugin_lzma2 compressPlugin = lzma2CompressPlugin; - compressPlugin.compress_level = 9; - compressPlugin.dict_size = myBestDictSize; - compressPlugin.thread_num = 1; + TCompressPlugin_lzma2 compressPlugin; + configure_lzma2(compressPlugin); create_single_compressed_diff(_new, _new + newsize, old, old + oldsize, out_codeBuf, 0, &compressPlugin.base, myBestSingleMatchScore, myBestStepMemSize); @@ -32,17 +277,64 @@ void hdiff(const uint8_t* old, size_t oldsize, const uint8_t* _new, size_t newsi } } +HdiffWithCoversResult hdiff_with_covers(const uint8_t* old, size_t oldsize, + const uint8_t* _new, size_t newsize, + const HdiffCover* covers, size_t coverCount, + HdiffCoverMode coverMode, + std::vector& out_codeBuf) { + const int myBestSingleMatchScore = 3; + const size_t myBestStepMemSize = kDefaultStepMemSize; + + if (coverCount > 0 && covers == nullptr) { + throw std::runtime_error("Invalid cover list."); + } + validate_covers(covers, coverCount, oldsize, newsize); + + hpatch_TDecompress* decompressPlugin = &lzma2DecompressPlugin; + TCompressPlugin_lzma2 compressPlugin; + configure_lzma2(compressPlugin); + + CoverLinesListener listener = { + { coverLines }, + covers, + coverCount, + 0, + 0, + false, + false, + coverMode, + false, + {}, + {}, + }; + + create_single_compressed_diff(_new, _new + newsize, old, old + oldsize, out_codeBuf, + &listener.base, &compressPlugin.base, myBestSingleMatchScore, + myBestStepMemSize); + + if (!check_single_compressed_diff(_new, _new + newsize, old, old + oldsize, out_codeBuf.data(), + out_codeBuf.data() + out_codeBuf.size(), decompressPlugin)) { + throw std::runtime_error("check_single_compressed_diff() failed, diff code error!"); + } + + return HdiffWithCoversResult{ + listener.usedCovers, + coverCount, + listener.nativeCoverCapacity, + listener.finalCoverCount, + to_hdiff_covers(listener.nativeCovers), + to_hdiff_covers(listener.preparedCovers), + }; +} + void hdiff_stream(const char* oldPath,const char* newPath,const char* outDiffPath){ if (!oldPath || !newPath || !outDiffPath) { throw std::runtime_error("Invalid file path."); } - const size_t myBestDictSize = (1 << 20) * 8; // 固定 8MB,与 v1.0.6 一致 hpatch_TDecompress* decompressPlugin = &lzma2DecompressPlugin; - TCompressPlugin_lzma2 compressPlugin = lzma2CompressPlugin; - compressPlugin.compress_level = 9; - compressPlugin.dict_size = myBestDictSize; - compressPlugin.thread_num = 1; + TCompressPlugin_lzma2 compressPlugin; + configure_lzma2(compressPlugin); hpatch_TFileStreamInput oldStream; hpatch_TFileStreamInput newStream; diff --git a/src/hdiff.h b/src/hdiff.h index 8440741..d6ca073 100644 --- a/src/hdiff.h +++ b/src/hdiff.h @@ -8,8 +8,34 @@ #include #include +struct HdiffCover { + uint64_t oldPos; + uint64_t newPos; + uint64_t length; +}; + +enum class HdiffCoverMode { + Replace, + Merge, + NativeCoalesce, +}; + +struct HdiffWithCoversResult { + bool usedCovers; + size_t requestedCoverCount; + size_t nativeCoverCapacity; + size_t finalCoverCount; + std::vector nativeCovers; + std::vector finalCovers; +}; + void hdiff(const uint8_t* old,size_t oldsize,const uint8_t* _new,size_t newsize, std::vector& out_codeBuf); +HdiffWithCoversResult hdiff_with_covers(const uint8_t* old, size_t oldsize, + const uint8_t* _new, size_t newsize, + const HdiffCover* covers, size_t coverCount, + HdiffCoverMode coverMode, + std::vector& out_codeBuf); void hdiff_stream(const char* oldPath,const char* newPath,const char* outDiffPath); #endif diff --git a/src/hpatch.cpp b/src/hpatch.cpp index 55bc64d..f0931b1 100644 --- a/src/hpatch.cpp +++ b/src/hpatch.cpp @@ -5,6 +5,7 @@ #include "hpatch.h" #include "../HDiffPatch/libHDiffPatch/HPatch/patch.h" #include "../HDiffPatch/file_for_patch.h" +#include #include #define _CompressPlugin_lzma2 @@ -25,7 +26,14 @@ static hpatch_BOOL onDiffInfo(sspatch_listener_t* listener, unsigned char** out_temp_cache, unsigned char** out_temp_cacheEnd) { PatchListener* self = (PatchListener*)listener->import; - + + // stepMemSize 来自 diff 数据,拒绝超出 size_t 可表示范围的声明值 + const hpatch_StreamPos_t maxCacheSize = + (hpatch_StreamPos_t)(std::numeric_limits::max() - hpatch_kStreamCacheSize * 4); + if (info->stepMemSize > maxCacheSize) { + return hpatch_FALSE; + } + // Allocate temp cache: stepMemSize + I/O cache size_t cacheSize = (size_t)info->stepMemSize + hpatch_kStreamCacheSize * 4; self->tempCache->resize(cacheSize); @@ -51,7 +59,13 @@ void hpatch(const uint8_t* old, size_t oldsize, if (diffInfo.oldDataSize != oldsize) { throw std::runtime_error("Old data size mismatch!"); } - + + // newDataSize 来自 diff 数据,防止 uint64 → size_t 截断(32 位平台) + if (diffInfo.newDataSize > + (hpatch_StreamPos_t)std::numeric_limits::max()) { + throw std::runtime_error("Invalid diff data: declared new size is too large!"); + } + // Allocate output buffer out_newBuf.resize((size_t)diffInfo.newDataSize); @@ -78,6 +92,69 @@ void hpatch(const uint8_t* old, size_t oldsize, } } +void hpatch_single_stream(const char* oldPath,const char* diffPath,const char* outNewPath){ + if (!oldPath || !diffPath || !outNewPath) { + throw std::runtime_error("Invalid file path."); + } + + hpatch_TDecompress* decompressPlugin = &lzma2DecompressPlugin; + + hpatch_TFileStreamInput oldStream; + hpatch_TFileStreamInput diffStream; + hpatch_TFileStreamOutput newStream; + hpatch_TFileStreamInput_init(&oldStream); + hpatch_TFileStreamInput_init(&diffStream); + hpatch_TFileStreamOutput_init(&newStream); + + bool oldOpened = false; + bool diffOpened = false; + bool newOpened = false; + + try { + if (!hpatch_TFileStreamInput_open(&oldStream, oldPath)) { + throw std::runtime_error("open old file failed."); + } + oldOpened = true; + if (!hpatch_TFileStreamInput_open(&diffStream, diffPath)) { + throw std::runtime_error("open diff file failed."); + } + diffOpened = true; + if (!hpatch_TFileStreamOutput_open(&newStream, outNewPath, ~(hpatch_StreamPos_t)0)) { + throw std::runtime_error("open new file for write failed."); + } + newOpened = true; + + std::vector tempCache; + PatchListener patchListener; + patchListener.decompressPlugin = decompressPlugin; + patchListener.tempCache = &tempCache; + + sspatch_listener_t listener; + listener.import = &patchListener; + listener.onDiffInfo = onDiffInfo; + listener.onPatchFinish = nullptr; + + if (!patch_single_stream_by(&listener, &newStream.base, &oldStream.base, &diffStream.base, 0)) { + throw std::runtime_error("patch_single_stream_by() failed!"); + } + } catch (...) { + if (newOpened) hpatch_TFileStreamOutput_close(&newStream); + if (diffOpened) hpatch_TFileStreamInput_close(&diffStream); + if (oldOpened) hpatch_TFileStreamInput_close(&oldStream); + throw; + } + + if (newOpened && !hpatch_TFileStreamOutput_close(&newStream)) { + throw std::runtime_error("close new file failed."); + } + if (diffOpened && !hpatch_TFileStreamInput_close(&diffStream)) { + throw std::runtime_error("close diff file failed."); + } + if (oldOpened && !hpatch_TFileStreamInput_close(&oldStream)) { + throw std::runtime_error("close old file failed."); + } +} + void hpatch_stream(const char* oldPath,const char* diffPath,const char* outNewPath){ if (!oldPath || !diffPath || !outNewPath) { throw std::runtime_error("Invalid file path."); diff --git a/src/hpatch.h b/src/hpatch.h index 7059f2c..936849c 100644 --- a/src/hpatch.h +++ b/src/hpatch.h @@ -12,6 +12,7 @@ void hpatch(const uint8_t* old, size_t oldsize, const uint8_t* diff, size_t diffsize, std::vector& out_newBuf); +void hpatch_single_stream(const char* oldPath,const char* diffPath,const char* outNewPath); void hpatch_stream(const char* oldPath,const char* diffPath,const char* outNewPath); #endif diff --git a/src/main.cc b/src/main.cc index 4c925c6..840610c 100644 --- a/src/main.cc +++ b/src/main.cc @@ -4,7 +4,10 @@ * Created by housisong on 2021.04.07, refactored 2026.01.20 */ #include +#include +#include #include +#include #include #include #include @@ -37,6 +40,134 @@ namespace hdiffpatchNode return true; } + inline bool parseUint64String(const std::string& value, uint64_t* out) { + if (value.empty()) return false; + errno = 0; + char* end = nullptr; + unsigned long long parsed = std::strtoull(value.c_str(), &end, 10); + if (errno == ERANGE || end == value.c_str() || *end != '\0') return false; + *out = static_cast(parsed); + return true; + } + + inline bool getUint64Value(const Napi::Value& value, uint64_t* out) { + if (value.IsString()) { + return parseUint64String(value.As().Utf8Value(), out); + } + if (value.IsNumber()) { + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number < 0 || std::floor(number) != number || + number > static_cast(std::numeric_limits::max())) { + return false; + } + *out = static_cast(number); + return true; + } + if (value.IsBigInt()) { + bool lossless = false; + uint64_t parsed = value.As().Uint64Value(&lossless); + if (!lossless) return false; + *out = parsed; + return true; + } + return false; + } + + inline bool getCoverField(const Napi::Object& cover, const char* primary, + const char* fallback, uint64_t* out) { + if (cover.Has(primary) && getUint64Value(cover.Get(primary), out)) { + return true; + } + if (fallback && cover.Has(fallback) && getUint64Value(cover.Get(fallback), out)) { + return true; + } + return false; + } + + std::vector getCoverList(const Napi::Value& arg) { + if (!arg.IsArray()) { + throw std::runtime_error("Invalid arguments: expected covers array."); + } + + Napi::Array array = arg.As(); + std::vector covers; + covers.reserve(array.Length()); + + for (uint32_t i = 0; i < array.Length(); ++i) { + Napi::Value item = array.Get(i); + if (!item.IsObject()) { + throw std::runtime_error("Invalid cover: expected object."); + } + Napi::Object coverObject = item.As(); + HdiffCover cover = {0, 0, 0}; + if (!getCoverField(coverObject, "oldPos", "old_pos", &cover.oldPos) || + !getCoverField(coverObject, "newPos", "new_pos", &cover.newPos) || + !getCoverField(coverObject, "len", "length", &cover.length)) { + throw std::runtime_error("Invalid cover: expected oldPos, newPos, and len."); + } + covers.push_back(cover); + } + + return covers; + } + + HdiffCoverMode getCoverMode(const Napi::CallbackInfo& info, size_t optionsIndex) { + if (info.Length() <= optionsIndex || info[optionsIndex].IsUndefined() || info[optionsIndex].IsNull()) { + return HdiffCoverMode::Replace; + } + if (!info[optionsIndex].IsObject()) { + throw std::runtime_error("Invalid options: expected object."); + } + + Napi::Object options = info[optionsIndex].As(); + if (!options.Has("mode") || options.Get("mode").IsUndefined() || options.Get("mode").IsNull()) { + return HdiffCoverMode::Replace; + } + if (!options.Get("mode").IsString()) { + throw std::runtime_error("Invalid options.mode: expected 'replace', 'merge', or 'native-coalesce'."); + } + + const std::string mode = options.Get("mode").As().Utf8Value(); + if (mode == "replace") { + return HdiffCoverMode::Replace; + } + if (mode == "merge") { + return HdiffCoverMode::Merge; + } + if (mode == "native-coalesce" || mode == "native_coalesce") { + return HdiffCoverMode::NativeCoalesce; + } + throw std::runtime_error("Invalid options.mode: expected 'replace', 'merge', or 'native-coalesce'."); + } + + bool getDebugCoversOption(const Napi::CallbackInfo& info, size_t optionsIndex) { + if (info.Length() <= optionsIndex || info[optionsIndex].IsUndefined() || info[optionsIndex].IsNull()) { + return false; + } + if (!info[optionsIndex].IsObject()) { + throw std::runtime_error("Invalid options: expected object."); + } + + Napi::Object options = info[optionsIndex].As(); + if (!options.Has("debugCovers")) { + return false; + } + Napi::Value debugCovers = options.Get("debugCovers"); + return debugCovers.IsBoolean() && debugCovers.As().Value(); + } + + const char* coverModeName(HdiffCoverMode mode) { + switch (mode) { + case HdiffCoverMode::Merge: + return "merge"; + case HdiffCoverMode::NativeCoalesce: + return "native-coalesce"; + case HdiffCoverMode::Replace: + default: + return "replace"; + } + } + inline Napi::Buffer bufferFromVector(Napi::Env env, std::vector&& data) { if (data.empty()) { return Napi::Buffer::New(env, 0); @@ -53,6 +184,22 @@ namespace hdiffpatchNode ); } + Napi::Object coverToObject(Napi::Env env, const HdiffCover& cover) { + Napi::Object object = Napi::Object::New(env); + object.Set(Napi::String::New(env, "oldPos"), Napi::String::New(env, std::to_string(cover.oldPos))); + object.Set(Napi::String::New(env, "newPos"), Napi::String::New(env, std::to_string(cover.newPos))); + object.Set(Napi::String::New(env, "len"), Napi::String::New(env, std::to_string(cover.length))); + return object; + } + + Napi::Array coversToArray(Napi::Env env, const std::vector& covers) { + Napi::Array array = Napi::Array::New(env, covers.size()); + for (uint32_t i = 0; i < covers.size(); ++i) { + array.Set(i, coverToObject(env, covers[i])); + } + return array; + } + // ============ 异步 Diff Worker ============ class DiffAsyncWorker : public Napi::AsyncWorker { public: @@ -233,6 +380,45 @@ namespace hdiffpatchNode std::string outNewPath_; }; + // ============ 异步 Single-compressed Patch Worker ============ + class PatchSingleStreamAsyncWorker : public Napi::AsyncWorker { + public: + PatchSingleStreamAsyncWorker(Napi::Function& callback, + std::string oldPath, + std::string diffPath, + std::string outNewPath) + : Napi::AsyncWorker(callback), + oldPath_(std::move(oldPath)), + diffPath_(std::move(diffPath)), + outNewPath_(std::move(outNewPath)) { + } + + void Execute() override { + try { + hpatch_single_stream(oldPath_.c_str(), diffPath_.c_str(), outNewPath_.c_str()); + } catch (const std::exception& e) { + SetError(e.what()); + } + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::HandleScope scope(env); + Callback().Call({env.Null(), Napi::String::New(env, outNewPath_)}); + } + + void OnError(const Napi::Error& e) override { + Napi::Env env = Env(); + Napi::HandleScope scope(env); + Callback().Call({e.Value()}); + } + + private: + std::string oldPath_; + std::string diffPath_; + std::string outNewPath_; + }; + // ============ 同步/异步 diff ============ Napi::Value diff(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); @@ -272,6 +458,64 @@ namespace hdiffpatchNode return bufferFromVector(env, std::move(codeBuf)); } + // ============ 同步 diffWithCovers ============ + Napi::Value diffWithCovers(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + const uint8_t* oldData = nullptr; + size_t oldLength = 0; + const uint8_t* newData = nullptr; + size_t newLength = 0; + + if (info.Length() < 3 || + !getBufferData(info[0], &oldData, &oldLength) || + !getBufferData(info[1], &newData, &newLength)) { + Napi::TypeError::New(env, "Invalid arguments: expected Buffer or TypedArray and covers array.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::vector covers; + HdiffCoverMode coverMode = HdiffCoverMode::Replace; + bool debugCovers = false; + try { + covers = getCoverList(info[2]); + coverMode = getCoverMode(info, 3); + debugCovers = getDebugCoversOption(info, 3); + } catch (const std::exception& e) { + Napi::TypeError::New(env, e.what()).ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::vector codeBuf; + HdiffWithCoversResult coverResult = {false, 0, 0, 0, {}, {}}; + try { + coverResult = hdiff_with_covers(oldData, oldLength, newData, newLength, + covers.empty() ? nullptr : covers.data(), + covers.size(), coverMode, codeBuf); + } catch (const std::exception& e) { + Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); + return env.Undefined(); + } + + Napi::Object result = Napi::Object::New(env); + result.Set(Napi::String::New(env, "diff"), bufferFromVector(env, std::move(codeBuf))); + result.Set(Napi::String::New(env, "usedCovers"), Napi::Boolean::New(env, coverResult.usedCovers)); + result.Set(Napi::String::New(env, "requestedCoverCount"), + Napi::Number::New(env, static_cast(coverResult.requestedCoverCount))); + result.Set(Napi::String::New(env, "nativeCoverCapacity"), + Napi::Number::New(env, static_cast(coverResult.nativeCoverCapacity))); + result.Set(Napi::String::New(env, "finalCoverCount"), + Napi::Number::New(env, static_cast(coverResult.finalCoverCount))); + result.Set(Napi::String::New(env, "coverMode"), + Napi::String::New(env, coverModeName(coverMode))); + if (debugCovers) { + result.Set(Napi::String::New(env, "nativeCovers"), coversToArray(env, coverResult.nativeCovers)); + result.Set(Napi::String::New(env, "finalCovers"), coversToArray(env, coverResult.finalCovers)); + } + return result; + } + // ============ 同步/异步 patch ============ Napi::Value patch(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); @@ -387,14 +631,52 @@ namespace hdiffpatchNode return Napi::String::New(env, outNewPath); } + // ============ 同步/异步 patchSingleStream ============ + Napi::Value patchSingleStream(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + std::string oldPath; + std::string diffPath; + std::string outNewPath; + if (info.Length() < 3 || + !getStringUtf8(info[0], oldPath) || + !getStringUtf8(info[1], diffPath) || + !getStringUtf8(info[2], outNewPath)) { + Napi::TypeError::New(env, "Invalid arguments: expected (oldPath, diffPath, outNewPath).") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + if (info.Length() > 3 && info[3].IsFunction()) { + Napi::Function callback = info[3].As(); + PatchSingleStreamAsyncWorker* worker = new PatchSingleStreamAsyncWorker( + callback, oldPath, diffPath, outNewPath + ); + worker->Queue(); + return env.Undefined(); + } + + try { + hpatch_single_stream(oldPath.c_str(), diffPath.c_str(), outNewPath.c_str()); + } catch (const std::exception& e) { + Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); + return env.Undefined(); + } + + return Napi::String::New(env, outNewPath); + } + Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set(Napi::String::New(env, "diff"), Napi::Function::New(env, diff)); + exports.Set(Napi::String::New(env, "diffWithCovers"), Napi::Function::New(env, diffWithCovers)); exports.Set(Napi::String::New(env, "patch"), Napi::Function::New(env, patch)); exports.Set(Napi::String::New(env, "diffStream"), Napi::Function::New(env, diffStream)); exports.Set(Napi::String::New(env, "patchStream"), Napi::Function::New(env, patchStream)); + exports.Set(Napi::String::New(env, "patchSingleStream"), Napi::Function::New(env, patchSingleStream)); return exports; } - NODE_API_MODULE(hdiffpatch, Init) +} // namespace hdiffpatchNode -} // namespace hdiffpatch +using hdiffpatchNode::Init; +NODE_API_MODULE(hdiffpatch, Init) From 11e41f7247ac0d35465dc6a897234217c9854af0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 5 Jul 2026 20:59:22 +0800 Subject: [PATCH 2/4] Make native module loading robust and switch to prebuilds-only install - Loader prefers a local build/Release build (dev), then static per-platform prebuild paths incl. darwin-x64/win32-x64, and falls back to node-gyp-build with a clear error on unsupported platforms; a missing or ABI-incompatible prebuild no longer crashes require() - Drop the install script: the published package has no sources, so node-gyp fallback could only ever fail; move node-addon-api to devDependencies and declare engines.node >=14.17 - Remove redundant .npmignore (files field wins); anchor the version tag regex in prepublish.ts Co-Authored-By: Claude Fable 5 --- .npmignore | 2 -- bun.lock | 2 +- index.js | 36 +++++++++++++++++++++++++----------- package.json | 9 ++++++--- scripts/prepublish.ts | 4 ++-- 5 files changed, 34 insertions(+), 19 deletions(-) delete mode 100644 .npmignore diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 08fe6ed..0000000 --- a/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -.idea -build diff --git a/bun.lock b/bun.lock index 879e37e..fd08bd8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,11 @@ "": { "name": "node-hdiffpatch", "dependencies": { - "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.1", }, "devDependencies": { "@types/bun": "^1.3.8", + "node-addon-api": "^8.5.0", "node-gyp": "^12.2.0", "prebuildify": "^6.0.0", }, diff --git a/index.js b/index.js index f86d23a..a41e6f6 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,29 @@ -function loadNative() { - const platform = process.platform; - const arch = process.arch; +const fs = require('fs'); +const path = require('path'); - if (platform === 'darwin' && arch === 'arm64') { - return require('./prebuilds/darwin-arm64/node-hdiffpatch.node'); - } - if (platform === 'linux' && arch === 'x64') { - return require('./prebuilds/linux-x64/node-hdiffpatch.node'); - } - if (platform === 'linux' && arch === 'arm64') { - return require('./prebuilds/linux-arm64/node-hdiffpatch.node'); +function loadNative() { + // 开发环境:本地编译产物优先于随包分发的 prebuild(与 node-gyp-build 的顺序一致) + const localBuild = path.join(__dirname, 'build/Release/hdiffpatch.node'); + if (fs.existsSync(localBuild)) { + return require(localBuild); } + // 静态 require 路径便于打包工具分析;失败(缺文件、musl 等 ABI 不符)时回退 node-gyp-build + try { + switch (`${process.platform}-${process.arch}`) { + case 'darwin-arm64': + return require('./prebuilds/darwin-arm64/node-hdiffpatch.node'); + case 'darwin-x64': + return require('./prebuilds/darwin-x64/node-hdiffpatch.node'); + case 'linux-x64': + return require('./prebuilds/linux-x64/node-hdiffpatch.node'); + case 'linux-arm64': + return require('./prebuilds/linux-arm64/node-hdiffpatch.node'); + case 'win32-x64': + return require('./prebuilds/win32-x64/node-hdiffpatch.node'); + } + } catch (err) {} + return require('node-gyp-build')(__dirname); } @@ -20,6 +32,8 @@ const native = loadNative(); exports.native = native; exports.diff = native.diff; +exports.diffWithCovers = native.diffWithCovers; exports.patch = native.patch; exports.diffStream = native.diffStream; exports.patchStream = native.patchStream; +exports.patchSingleStream = native.patchSingleStream; diff --git a/package.json b/package.json index f07336b..5b3e1b6 100644 --- a/package.json +++ b/package.json @@ -15,20 +15,23 @@ ], "scripts": { "test": "node test/test.js", + "test:bun": "bun ./test/test.js", "prepublishOnly": "bun scripts/prepublish.ts", "benchmark": "node --expose-gc test/benchmark.js", - "prebuild": "prebuildify --napi --strip", - "install": "node-gyp-build" + "prebuild": "prebuildify --napi --strip" }, "gypfile": true, "author": "housisong, sunnylqm", "license": "MIT", + "engines": { + "node": ">=14.17.0" + }, "dependencies": { - "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.1" }, "devDependencies": { "@types/bun": "^1.3.8", + "node-addon-api": "^8.5.0", "node-gyp": "^12.2.0", "prebuildify": "^6.0.0" }, diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts index 45b6e66..bae122c 100644 --- a/scripts/prepublish.ts +++ b/scripts/prepublish.ts @@ -36,8 +36,8 @@ async function modifyPackageJson({ async function main(): Promise { const version = (await $`git describe --tags --always`.text()) - .replace('v', '') - .trim(); + .trim() + .replace(/^v/, ''); try { await modifyPackageJson({ version }); console.log('✅ Prepublish script completed successfully'); From 5b99ef20870b4acd0c181afd4bb21ea48474f0da Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 5 Jul 2026 20:59:33 +0800 Subject: [PATCH 3/4] Add CI test matrix, Bun compat tests, CLI format auto-detection - New ci.yml: build + test on push/PR across linux x64/arm64, macOS Intel/arm64, and Windows, under both Node and Bun - publish.yml: restore macos-15-intel and windows-latest prebuilds, run tests before uploading artifacts, publish with --provenance - hdp patch now sniffs the diff header (HDIFF13 / HDIFFSF20) and applies the right patcher, so CLI can apply diff() output too - Tests: async callback paths for all five APIs, corrupt-diff error propagation, CLI coverage for both formats; ci-failure-email.yml tracked - README: platform support, development guide, format notes, licenses Co-Authored-By: Claude Fable 5 --- .github/workflows/ci-failure-email.yml | 115 +++++++++++++++++++++ .github/workflows/ci.yml | 33 ++++++ .github/workflows/publish.yml | 14 +-- README.md | 81 ++++++++++++++- bin/hdiffpatch.js | 27 ++++- test/test.js | 136 ++++++++++++++++++++++++- 6 files changed, 394 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci-failure-email.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci-failure-email.yml b/.github/workflows/ci-failure-email.yml new file mode 100644 index 0000000..f8f106a --- /dev/null +++ b/.github/workflows/ci-failure-email.yml @@ -0,0 +1,115 @@ +name: CI Failure Issue + +on: + workflow_run: + workflows: + - Publish Package to npmjs + types: [completed] + +jobs: + notify: + if: contains(fromJSON('["failure","timed_out","action_required"]'), github.event.workflow_run.conclusion) + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - name: Create or update failure issue + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + CONCLUSION: ${{ github.event.workflow_run.conclusion }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + ACTOR: ${{ github.event.workflow_run.actor.login }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + run: | + reported_at="$(TZ=Asia/Shanghai date '+%Y-%m-%d %H:%M:%S %z')" + issue_title_prefix="[CI failed] $REPOSITORY / $WORKFLOW_NAME at " + body_file="$(mktemp)" + cat > "$body_file" <-/ for the current machine +bun run test # run tests under Node +bun run test:bun # run the same tests under the Bun runtime ``` ## Usage @@ -24,6 +35,60 @@ bun run prebuild Compare two buffers and return a new hdiffpatch patch as return value. +### diffWithCovers(originBuf, newBuf, covers[, options]) + +Create a standard hdiffpatch patch while using caller-provided cover lines when +possible. By default, the supplied covers replace HDiffPatch's internal cover +selection. + +Each cover is `{ oldPos, newPos, len }`. Values may be numbers, decimal strings, +or bigint values. The returned object is: + +```js +{ + diff: Buffer, + usedCovers: boolean, + requestedCoverCount: number, + nativeCoverCapacity: number, + finalCoverCount: number, + coverMode: 'replace' | 'merge' | 'native-coalesce', + nativeCovers?: HpatchCover[], + finalCovers?: HpatchCover[] +} +``` + +The `diff` buffer is still a normal hdiffpatch payload and can be applied with +`patch(originBuf, diff)` or an HDiffPatch-compatible apply side. + +Set `options.mode` to `merge` to keep HDiffPatch's native covers and only add +caller covers in new-file ranges not already covered by native covers: + +```js +const merged = hdiff.diffWithCovers(oldBuf, newBuf, covers, { mode: 'merge' }); +``` + +Set `options.mode` to `native-coalesce` to keep HDiffPatch's native cover +selection but coalesce adjacent native covers that have the same old/new offset +delta across small gaps. This remains a standard hdiffpatch payload and is useful +as a costed post-processing experiment: + +```js +const coalesced = hdiff.diffWithCovers(oldBuf, newBuf, [], { + mode: 'native-coalesce', +}); +``` + +Set `options.debugCovers` to `true` to include the native HDiffPatch cover list +and the final listener cover list in the return value. This is for diagnostics; +the default return shape avoids copying cover arrays. + +### patchSingleStream(oldPath, diffPath, outNewPath[, cb]) + +Apply a single-compressed hpatch payload created by `diff` or `diffWithCovers` +from files. This is the file-level apply path for the normal in-memory `diff` +format. In sync mode returns `outNewPath`. In async mode, callback signature is +`(err, outNewPath)`. + ### diffStream(oldPath, newPath, outDiffPath[, cb]) Create diff file by streaming file paths (low memory). In sync mode returns @@ -43,3 +108,13 @@ After install, you can run: hdp diff hdp patch ``` + +Note: `hdp patch` applies diffs created by `hdp diff` (streaming format). Diffs +created by the in-memory `diff()` / `diffWithCovers()` APIs must be applied with +`patch()` or `patchSingleStream()` instead. + +## License + +MIT. The prebuilt binaries statically include +[HDiffPatch](https://github.com/sisong/HDiffPatch) (MIT) and the +[LZMA SDK](https://github.com/sisong/lzma) (public domain). diff --git a/bin/hdiffpatch.js b/bin/hdiffpatch.js index 8a61ffd..2944e2c 100755 --- a/bin/hdiffpatch.js +++ b/bin/hdiffpatch.js @@ -1,6 +1,7 @@ #!/usr/bin/env node 'use strict'; +const fs = require('fs'); const hdiffpatch = require('..'); function usage() { @@ -12,11 +13,28 @@ function usage() { '', 'Notes:', ' - Uses streaming diff/patch for low memory usage.', + ' - patch auto-detects the diff format (diffStream or diff/diffWithCovers output).', ' - Outputs are files specified by /.', ].join('\n') ); } +// 两种 diff 格式的文件头:流式为 "HDIFF13",单压缩(diff()/diffWithCovers() 产物)为 "HDIFFSF20" +function detectDiffFormat(diffFile) { + const header = Buffer.alloc(9); + const fd = fs.openSync(diffFile, 'r'); + let bytesRead; + try { + bytesRead = fs.readSync(fd, header, 0, header.length, 0); + } finally { + fs.closeSync(fd); + } + const magic = header.slice(0, bytesRead).toString('latin1'); + if (magic.startsWith('HDIFFSF20')) return 'single'; + if (magic.startsWith('HDIFF13')) return 'stream'; + return null; +} + function fail(msg) { if (msg) console.error(`Error: ${msg}`); usage(); @@ -50,7 +68,14 @@ if (cmd === 'patch') { const diffFile = args[2]; const outNew = args[3]; try { - hdiffpatch.patchStream(oldFile, diffFile, outNew); + const format = detectDiffFormat(diffFile); + if (format === 'single') { + hdiffpatch.patchSingleStream(oldFile, diffFile, outNew); + } else if (format === 'stream') { + hdiffpatch.patchStream(oldFile, diffFile, outNew); + } else { + throw new Error(`${diffFile} is not a recognized hdiffpatch diff file.`); + } console.log(outNew); } catch (err) { fail(err && err.message ? err.message : String(err)); diff --git a/test/test.js b/test/test.js index abbe3d4..e14952d 100644 --- a/test/test.js +++ b/test/test.js @@ -52,15 +52,81 @@ var uint8Patched = hdiffpatch.patch(uint8Old, uint8Diff); assert.deepStrictEqual(Buffer.from(uint8Patched), newData); console.log(" ✓ Uint8Array works"); -console.log("\nTest 5: Stream diff/patch (file paths)..."); +console.log("\nTest 5: diffWithCovers emits patchable hdiff..."); +var coverOld = Buffer.from("abcXYZdef"); +var coverNew = Buffer.from("abc123def"); +var coverResult = hdiffpatch.diffWithCovers(coverOld, coverNew, [ + { oldPos: "0", newPos: "0", len: "3" }, + { oldPos: "6", newPos: "6", len: "3" }, +]); +assert(Buffer.isBuffer(coverResult.diff)); +assert.strictEqual(coverResult.usedCovers, true); +assert.strictEqual(coverResult.requestedCoverCount, 2); +assert(Number.isInteger(coverResult.nativeCoverCapacity)); +assert(coverResult.nativeCoverCapacity >= 0); +assert.strictEqual(coverResult.finalCoverCount, 2); +assert.strictEqual(coverResult.coverMode, "replace"); +assert.deepStrictEqual(hdiffpatch.patch(coverOld, coverResult.diff), coverNew); +var mergeCoverResult = hdiffpatch.diffWithCovers(coverOld, coverNew, [ + { oldPos: "0", newPos: "0", len: "3" }, + { oldPos: "6", newPos: "6", len: "3" }, +], { mode: "merge", debugCovers: true }); +assert(Buffer.isBuffer(mergeCoverResult.diff)); +assert.strictEqual(mergeCoverResult.usedCovers, true); +assert.strictEqual(mergeCoverResult.requestedCoverCount, 2); +assert.strictEqual(mergeCoverResult.coverMode, "merge"); +assert(Number.isInteger(mergeCoverResult.finalCoverCount)); +assert(mergeCoverResult.finalCoverCount >= mergeCoverResult.nativeCoverCapacity); +assert(Array.isArray(mergeCoverResult.nativeCovers)); +assert(Array.isArray(mergeCoverResult.finalCovers)); +assert.strictEqual(mergeCoverResult.nativeCovers.length, mergeCoverResult.nativeCoverCapacity); +assert.strictEqual(mergeCoverResult.finalCovers.length, mergeCoverResult.finalCoverCount); +assert.deepStrictEqual(hdiffpatch.patch(coverOld, mergeCoverResult.diff), coverNew); +var coalescedCoverResult = hdiffpatch.diffWithCovers(coverOld, coverNew, [], { + mode: "native-coalesce", + debugCovers: true, +}); +assert(Buffer.isBuffer(coalescedCoverResult.diff)); +assert.strictEqual(coalescedCoverResult.usedCovers, true); +assert.strictEqual(coalescedCoverResult.requestedCoverCount, 0); +assert.strictEqual(coalescedCoverResult.coverMode, "native-coalesce"); +assert(Array.isArray(coalescedCoverResult.nativeCovers)); +assert(Array.isArray(coalescedCoverResult.finalCovers)); +assert(coalescedCoverResult.finalCoverCount <= coalescedCoverResult.nativeCoverCapacity); +assert.deepStrictEqual(hdiffpatch.patch(coverOld, coalescedCoverResult.diff), coverNew); +assert.throws( + () => hdiffpatch.diffWithCovers(coverOld, coverNew, [ + { oldPos: "99", newPos: "0", len: "1" }, + ]), + /old range is out of bounds/ +); +assert.throws( + () => hdiffpatch.diffWithCovers(coverOld, coverNew, [], { mode: "invalid" }), + /Invalid options.mode/ +); +console.log(" ✓ diffWithCovers patch(old, diff) === new"); + +console.log("\nTest 6: Single-compressed patchSingleStream (file paths)..."); var tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hdiffpatch-")); var oldPath = path.join(tempDir, "old.bin"); var newPath = path.join(tempDir, "new.bin"); var diffPath = path.join(tempDir, "diff.bin"); var outNewPath = path.join(tempDir, "out.bin"); +var singleDiffPath = path.join(tempDir, "single.diff"); +var singleOutNewPath = path.join(tempDir, "single-out.bin"); fs.writeFileSync(oldPath, oldData); fs.writeFileSync(newPath, newData); +fs.writeFileSync(singleDiffPath, diffResult); + +var singlePatchedOutPath = hdiffpatch.patchSingleStream(oldPath, singleDiffPath, singleOutNewPath); +assert.strictEqual(singlePatchedOutPath, singleOutNewPath); + +var singleOutNewData = fs.readFileSync(singleOutNewPath); +assert.deepStrictEqual(singleOutNewData, newData); +console.log(" ✓ patchSingleStream applies diff(old, new) output"); + +console.log("\nTest 7: Stream diff/patch (file paths)..."); var diffOutPath = hdiffpatch.diffStream(oldPath, newPath, diffPath); assert.strictEqual(diffOutPath, diffPath); @@ -71,4 +137,70 @@ var outNewData = fs.readFileSync(outNewPath); assert.deepStrictEqual(outNewData, newData); console.log(" ✓ Stream diff/patch works"); -fs.rmSync(tempDir, { recursive: true, force: true }); +// ---- 异步与 CLI 测试 ---- +var util = require("util"); +var execFile = util.promisify(require("child_process").execFile); +var diffAsync = util.promisify(hdiffpatch.diff); +var patchAsync = util.promisify(hdiffpatch.patch); +var diffStreamAsync = util.promisify(hdiffpatch.diffStream); +var patchStreamAsync = util.promisify(hdiffpatch.patchStream); +var patchSingleStreamAsync = util.promisify(hdiffpatch.patchSingleStream); + +async function runAsyncTests() { + console.log("\nTest 8: Async diff/patch callbacks..."); + var asyncDiff = await diffAsync(oldData, newData); + assert(Buffer.isBuffer(asyncDiff)); + assert.deepStrictEqual(asyncDiff, diffResult); + var asyncPatched = await patchAsync(oldData, asyncDiff); + assert.deepStrictEqual(asyncPatched, newData); + console.log(" ✓ Async diff/patch works"); + + console.log("\nTest 9: Async error propagation..."); + var corruptDiff = Buffer.from("this is definitely not a diff"); + await assert.rejects(() => patchAsync(oldData, corruptDiff)); + assert.throws(() => hdiffpatch.patch(oldData, corruptDiff)); + console.log(" ✓ Corrupt diff rejects in both sync and async modes"); + + console.log("\nTest 10: Async stream diff/patch callbacks..."); + var asyncDiffPath = path.join(tempDir, "async.diff"); + var asyncOutPath = path.join(tempDir, "async-out.bin"); + var asyncSingleOutPath = path.join(tempDir, "async-single-out.bin"); + assert.strictEqual(await diffStreamAsync(oldPath, newPath, asyncDiffPath), asyncDiffPath); + assert.strictEqual(await patchStreamAsync(oldPath, asyncDiffPath, asyncOutPath), asyncOutPath); + assert.deepStrictEqual(fs.readFileSync(asyncOutPath), newData); + assert.strictEqual( + await patchSingleStreamAsync(oldPath, singleDiffPath, asyncSingleOutPath), + asyncSingleOutPath + ); + assert.deepStrictEqual(fs.readFileSync(asyncSingleOutPath), newData); + await assert.rejects( + () => patchStreamAsync(oldPath, path.join(tempDir, "no-such.diff"), asyncOutPath) + ); + console.log(" ✓ Async stream diff/patch works"); + + console.log("\nTest 11: CLI auto-detects both diff formats..."); + var cliBin = path.join(__dirname, "..", "bin", "hdiffpatch.js"); + var cliDiffPath = path.join(tempDir, "cli.diff"); + var cliOutPath = path.join(tempDir, "cli-out.bin"); + var cliSingleOutPath = path.join(tempDir, "cli-single-out.bin"); + await execFile(process.execPath, [cliBin, "diff", oldPath, newPath, cliDiffPath]); + await execFile(process.execPath, [cliBin, "patch", oldPath, cliDiffPath, cliOutPath]); + assert.deepStrictEqual(fs.readFileSync(cliOutPath), newData); + await execFile(process.execPath, [cliBin, "patch", oldPath, singleDiffPath, cliSingleOutPath]); + assert.deepStrictEqual(fs.readFileSync(cliSingleOutPath), newData); + await assert.rejects( + () => execFile(process.execPath, [cliBin, "patch", oldPath, oldPath, cliOutPath]) + ); + console.log(" ✓ CLI patch handles stream, single-compressed, and invalid inputs"); +} + +runAsyncTests() + .then(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + console.log("\nAll tests passed."); + }) + .catch((err) => { + fs.rmSync(tempDir, { recursive: true, force: true }); + console.error(err); + process.exit(1); + }); From f5a0bc5ec2c292729ae57800ab88521887c08fee Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 5 Jul 2026 21:33:48 +0800 Subject: [PATCH 4/4] Address review feedback and fix Windows prebuild - Reject negative numeric strings in cover fields: strtoull wraps "-1" to UINT64_MAX (and e.g. "-18446744073709551615" silently to 1); add a regression test - Fix stale README note: hdp patch auto-detects both diff formats - Install node-gyp globally on Windows runners: bun creates exe shims while prebuildify spawns node-gyp.cmd, so the prebuild step failed Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 +++ .github/workflows/publish.yml | 3 +++ README.md | 6 +++--- src/main.cc | 2 ++ test/test.js | 6 ++++++ 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85920bf..92f809d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest + # bun 在 Windows 上生成 exe shim,而 prebuildify 硬编码 spawn node-gyp.cmd + - if: runner.os == 'Windows' + run: npm install -g node-gyp - run: bun install --ignore-scripts - run: bun run prebuild - run: node test/test.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ef543f..bfdd0ce 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,6 +34,9 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest + # bun 在 Windows 上生成 exe shim,而 prebuildify 硬编码 spawn node-gyp.cmd + - if: runner.os == 'Windows' + run: npm install -g node-gyp - run: bun install --ignore-scripts - run: bun run prebuild - run: node test/test.js diff --git a/README.md b/README.md index c43a433..668631a 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,9 @@ hdp diff hdp patch ``` -Note: `hdp patch` applies diffs created by `hdp diff` (streaming format). Diffs -created by the in-memory `diff()` / `diffWithCovers()` APIs must be applied with -`patch()` or `patchSingleStream()` instead. +Note: `hdp patch` auto-detects the diff format by its header, so it can apply +both streaming diffs created by `hdp diff` and single-compressed diffs created +by the in-memory `diff()` / `diffWithCovers()` APIs. ## License diff --git a/src/main.cc b/src/main.cc index 840610c..c64657e 100644 --- a/src/main.cc +++ b/src/main.cc @@ -42,6 +42,8 @@ namespace hdiffpatchNode inline bool parseUint64String(const std::string& value, uint64_t* out) { if (value.empty()) return false; + // strtoull 会接受前导 '-' 并做二补回绕("-1" → UINT64_MAX),显式拒绝 + if (value.find('-') != std::string::npos) return false; errno = 0; char* end = nullptr; unsigned long long parsed = std::strtoull(value.c_str(), &end, 10); diff --git a/test/test.js b/test/test.js index e14952d..a6be59c 100644 --- a/test/test.js +++ b/test/test.js @@ -104,6 +104,12 @@ assert.throws( () => hdiffpatch.diffWithCovers(coverOld, coverNew, [], { mode: "invalid" }), /Invalid options.mode/ ); +assert.throws( + () => hdiffpatch.diffWithCovers(coverOld, coverNew, [ + { oldPos: "-1", newPos: "0", len: "1" }, + ]), + /Invalid cover/ +); console.log(" ✓ diffWithCovers patch(old, diff) === new"); console.log("\nTest 6: Single-compressed patchSingleStream (file paths)...");