From f6af4a0e71c3950284c95c6a5f7f18d56339e0d8 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 11 Mar 2026 16:04:49 +0000 Subject: [PATCH 1/2] fix: exact match should check key as path segment, not string prefix listObjects uses MinIO prefix matching, so key "foo" also returns "foo-bar/cache.tzst". The previous fix (PR #60) used startsWith(key) which still has the same problem. Use startsWith(key + "/") to ensure the key matches as a full path segment. Add a workflow test that saves a decoy cache with a longer key sharing the same prefix, then verifies exact restore does not match it. --- .github/workflows/test-exact-match.yaml | 73 +++++++++++++++++++++++++ dist/restore/index.js | 18 +++--- dist/save/index.js | 18 +++--- dist/saveOnly/index.js | 18 +++--- src/utils.ts | 18 +++--- 5 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/test-exact-match.yaml diff --git a/.github/workflows/test-exact-match.yaml b/.github/workflows/test-exact-match.yaml new file mode 100644 index 0000000..93dcf2b --- /dev/null +++ b/.github/workflows/test-exact-match.yaml @@ -0,0 +1,73 @@ +name: test-exact-match + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # Save a "decoy" cache whose key is a prefix-extension of the exact key. + # e.g. key = "test-exact-Linux-12345-decoy" which shares the prefix + # "test-exact-Linux-12345" with the key we'll try to restore later. + test-save-decoy: + strategy: + matrix: + os: [ubuntu-latest] + fail-fast: false + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Generate decoy cache files + shell: bash + run: src/create-cache-files.sh ${{ runner.os }} test-cache + - name: Save decoy cache + uses: ./ + with: + endpoint: ${{ secrets.ENDPOINT }} + accessKey: ${{ secrets.ACCESS_KEY }} + secretKey: ${{ secrets.SECRET_KEY }} + bucket: ${{ secrets.BUCKET }} + use-fallback: false + key: test-exact-${{ runner.os }}-${{ github.run_id }}-decoy + path: test-cache + + # Try to restore with the shorter key that is a prefix of the decoy key. + # With correct exact matching this must NOT match the decoy. + test-exact-no-false-positive: + needs: test-save-decoy + strategy: + matrix: + os: [ubuntu-latest] + fail-fast: false + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Restore cache (should miss) + id: restore + uses: ./ + with: + endpoint: ${{ secrets.ENDPOINT }} + accessKey: ${{ secrets.ACCESS_KEY }} + secretKey: ${{ secrets.SECRET_KEY }} + bucket: ${{ secrets.BUCKET }} + use-fallback: false + key: test-exact-${{ runner.os }}-${{ github.run_id }} + path: test-cache + - name: Verify cache was NOT restored + shell: bash + env: + CACHE_HIT: ${{ steps.restore.outputs.cache-hit }} + run: | + echo "cache-hit=$CACHE_HIT" + if [ "$CACHE_HIT" = "true" ]; then + echo "FAIL: cache-hit should not be true — decoy was incorrectly matched" + exit 1 + fi + if [ -e test-cache/test-file.txt ]; then + echo "FAIL: test-file.txt should not exist — decoy files were restored" + exit 1 + fi + echo "PASS: exact match correctly rejected the decoy" diff --git a/dist/restore/index.js b/dist/restore/index.js index 549ff9b..b4eb9cc 100644 --- a/dist/restore/index.js +++ b/dist/restore/index.js @@ -86300,14 +86300,18 @@ function findObject(mc, bucket, key, restoreKeys, compressionMethod) { return __awaiter(this, void 0, void 0, function* () { core.debug("Key: " + JSON.stringify(key)); core.debug("Restore keys: " + JSON.stringify(restoreKeys)); - core.debug(`Finding exact macth for: ${key}`); - const exactMatch = yield listObjects(mc, bucket, key); - core.debug(`Found ${JSON.stringify(exactMatch, null, 2)}`); - if (exactMatch.length) { - const result = { item: exactMatch[0], matchingKey: key }; - core.debug(`Using ${JSON.stringify(result)}`); - return result; + core.debug(`Finding exact match for: ${key}`); + const keyMatches = yield listObjects(mc, bucket, key); + core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); + if (keyMatches.length > 0) { + const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + "/"); }); + if (exactMatch) { + const result = { item: exactMatch, matchingKey: key }; + core.debug(`Found an exact match; using ${JSON.stringify(result)}`); + return result; + } } + core.debug(`Didn't find an exact match`); for (const restoreKey of restoreKeys) { const fn = utils.getCacheFileName(compressionMethod); core.debug(`Finding object with prefix: ${restoreKey}`); diff --git a/dist/save/index.js b/dist/save/index.js index 4dc03f9..7840f63 100644 --- a/dist/save/index.js +++ b/dist/save/index.js @@ -86215,14 +86215,18 @@ function findObject(mc, bucket, key, restoreKeys, compressionMethod) { return __awaiter(this, void 0, void 0, function* () { core.debug("Key: " + JSON.stringify(key)); core.debug("Restore keys: " + JSON.stringify(restoreKeys)); - core.debug(`Finding exact macth for: ${key}`); - const exactMatch = yield listObjects(mc, bucket, key); - core.debug(`Found ${JSON.stringify(exactMatch, null, 2)}`); - if (exactMatch.length) { - const result = { item: exactMatch[0], matchingKey: key }; - core.debug(`Using ${JSON.stringify(result)}`); - return result; + core.debug(`Finding exact match for: ${key}`); + const keyMatches = yield listObjects(mc, bucket, key); + core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); + if (keyMatches.length > 0) { + const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + "/"); }); + if (exactMatch) { + const result = { item: exactMatch, matchingKey: key }; + core.debug(`Found an exact match; using ${JSON.stringify(result)}`); + return result; + } } + core.debug(`Didn't find an exact match`); for (const restoreKey of restoreKeys) { const fn = utils.getCacheFileName(compressionMethod); core.debug(`Finding object with prefix: ${restoreKey}`); diff --git a/dist/saveOnly/index.js b/dist/saveOnly/index.js index e480d95..cbf1f52 100644 --- a/dist/saveOnly/index.js +++ b/dist/saveOnly/index.js @@ -86215,14 +86215,18 @@ function findObject(mc, bucket, key, restoreKeys, compressionMethod) { return __awaiter(this, void 0, void 0, function* () { core.debug("Key: " + JSON.stringify(key)); core.debug("Restore keys: " + JSON.stringify(restoreKeys)); - core.debug(`Finding exact macth for: ${key}`); - const exactMatch = yield listObjects(mc, bucket, key); - core.debug(`Found ${JSON.stringify(exactMatch, null, 2)}`); - if (exactMatch.length) { - const result = { item: exactMatch[0], matchingKey: key }; - core.debug(`Using ${JSON.stringify(result)}`); - return result; + core.debug(`Finding exact match for: ${key}`); + const keyMatches = yield listObjects(mc, bucket, key); + core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); + if (keyMatches.length > 0) { + const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + "/"); }); + if (exactMatch) { + const result = { item: exactMatch, matchingKey: key }; + core.debug(`Found an exact match; using ${JSON.stringify(result)}`); + return result; + } } + core.debug(`Didn't find an exact match`); for (const restoreKey of restoreKeys) { const fn = utils.getCacheFileName(compressionMethod); core.debug(`Finding object with prefix: ${restoreKey}`); diff --git a/src/utils.ts b/src/utils.ts index a4d8d75..4497b07 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -128,14 +128,18 @@ export async function findObject( core.debug("Key: " + JSON.stringify(key)); core.debug("Restore keys: " + JSON.stringify(restoreKeys)); - core.debug(`Finding exact macth for: ${key}`); - const exactMatch = await listObjects(mc, bucket, key); - core.debug(`Found ${JSON.stringify(exactMatch, null, 2)}`); - if (exactMatch.length) { - const result = { item: exactMatch[0], matchingKey: key }; - core.debug(`Using ${JSON.stringify(result)}`); - return result; + core.debug(`Finding exact match for: ${key}`); + const keyMatches = await listObjects(mc, bucket, key); + core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); + if (keyMatches.length > 0) { + const exactMatch = keyMatches.find((obj) => obj.name?.startsWith(key + "/")); + if (exactMatch) { + const result = { item: exactMatch, matchingKey: key }; + core.debug(`Found an exact match; using ${JSON.stringify(result)}`); + return result; + } } + core.debug(`Didn't find an exact match`); for (const restoreKey of restoreKeys) { const fn = utils.getCacheFileName(compressionMethod); From e138471582e9c930c313d1a214d4ab1cdc9080e0 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 11 Mar 2026 16:07:50 +0000 Subject: [PATCH 2/2] fix: use path.sep instead of hardcoded "/" for Windows compatibility On Windows, path.join produces backslash separators, so MinIO objects have "\" in their names. The exact match check must use path.sep to match the platform's separator. --- dist/restore/index.js | 2 +- dist/save/index.js | 2 +- dist/saveOnly/index.js | 2 +- src/utils.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dist/restore/index.js b/dist/restore/index.js index b4eb9cc..5f72acb 100644 --- a/dist/restore/index.js +++ b/dist/restore/index.js @@ -86304,7 +86304,7 @@ function findObject(mc, bucket, key, restoreKeys, compressionMethod) { const keyMatches = yield listObjects(mc, bucket, key); core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); if (keyMatches.length > 0) { - const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + "/"); }); + const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + path_1.default.sep); }); if (exactMatch) { const result = { item: exactMatch, matchingKey: key }; core.debug(`Found an exact match; using ${JSON.stringify(result)}`); diff --git a/dist/save/index.js b/dist/save/index.js index 7840f63..7af964c 100644 --- a/dist/save/index.js +++ b/dist/save/index.js @@ -86219,7 +86219,7 @@ function findObject(mc, bucket, key, restoreKeys, compressionMethod) { const keyMatches = yield listObjects(mc, bucket, key); core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); if (keyMatches.length > 0) { - const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + "/"); }); + const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + path_1.default.sep); }); if (exactMatch) { const result = { item: exactMatch, matchingKey: key }; core.debug(`Found an exact match; using ${JSON.stringify(result)}`); diff --git a/dist/saveOnly/index.js b/dist/saveOnly/index.js index cbf1f52..d6edfed 100644 --- a/dist/saveOnly/index.js +++ b/dist/saveOnly/index.js @@ -86219,7 +86219,7 @@ function findObject(mc, bucket, key, restoreKeys, compressionMethod) { const keyMatches = yield listObjects(mc, bucket, key); core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); if (keyMatches.length > 0) { - const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + "/"); }); + const exactMatch = keyMatches.find((obj) => { var _a; return (_a = obj.name) === null || _a === void 0 ? void 0 : _a.startsWith(key + path_1.default.sep); }); if (exactMatch) { const result = { item: exactMatch, matchingKey: key }; core.debug(`Found an exact match; using ${JSON.stringify(result)}`); diff --git a/src/utils.ts b/src/utils.ts index 4497b07..b7e2173 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -132,7 +132,7 @@ export async function findObject( const keyMatches = await listObjects(mc, bucket, key); core.debug(`Found ${JSON.stringify(keyMatches, null, 2)}`); if (keyMatches.length > 0) { - const exactMatch = keyMatches.find((obj) => obj.name?.startsWith(key + "/")); + const exactMatch = keyMatches.find((obj) => obj.name?.startsWith(key + path.sep)); if (exactMatch) { const result = { item: exactMatch, matchingKey: key }; core.debug(`Found an exact match; using ${JSON.stringify(result)}`);