diff --git a/README.md b/README.md index 744c28a..6354592 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Note that you can achieve the same goal on Linux/macOS with simply one command, * target: The target directory where the downloaded file lies. Will be automatically created if not exists. * auto-match: (optional, default `false`) If we find the URL from the text, or use the `url` parameter directly as the URL. * filename: (optional) The filename to use when saving. If not given, use the original filename from the URL. +* retry-times: (optional, default `0`) Times to retry on download failure. If not given, don't attempt to retry. ## Output * filename: The written file name. diff --git a/action.yml b/action.yml index 7b70eec..b39b1ec 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,10 @@ inputs: description: "Filename for the downloaded file" required: false default: "" + retry-times: + description: "Times to retry on download failure" + required: false + default: "0" outputs: filename: description: "Written file name" diff --git a/dist/160.index.js b/dist/160.index.js deleted file mode 100644 index 0abb395..0000000 --- a/dist/160.index.js +++ /dev/null @@ -1,452 +0,0 @@ -"use strict"; -exports.id = 160; -exports.ids = [160]; -exports.modules = { - -/***/ 160: -/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ "toFormData": () => (/* binding */ toFormData) -/* harmony export */ }); -/* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(294); -/* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(665); - - - -let s = 0; -const S = { - START_BOUNDARY: s++, - HEADER_FIELD_START: s++, - HEADER_FIELD: s++, - HEADER_VALUE_START: s++, - HEADER_VALUE: s++, - HEADER_VALUE_ALMOST_DONE: s++, - HEADERS_ALMOST_DONE: s++, - PART_DATA_START: s++, - PART_DATA: s++, - END: s++ -}; - -let f = 1; -const F = { - PART_BOUNDARY: f, - LAST_BOUNDARY: f *= 2 -}; - -const LF = 10; -const CR = 13; -const SPACE = 32; -const HYPHEN = 45; -const COLON = 58; -const A = 97; -const Z = 122; - -const lower = c => c | 0x20; - -const noop = () => {}; - -class MultipartParser { - /** - * @param {string} boundary - */ - constructor(boundary) { - this.index = 0; - this.flags = 0; - - this.onHeaderEnd = noop; - this.onHeaderField = noop; - this.onHeadersEnd = noop; - this.onHeaderValue = noop; - this.onPartBegin = noop; - this.onPartData = noop; - this.onPartEnd = noop; - - this.boundaryChars = {}; - - boundary = '\r\n--' + boundary; - const ui8a = new Uint8Array(boundary.length); - for (let i = 0; i < boundary.length; i++) { - ui8a[i] = boundary.charCodeAt(i); - this.boundaryChars[ui8a[i]] = true; - } - - this.boundary = ui8a; - this.lookbehind = new Uint8Array(this.boundary.length + 8); - this.state = S.START_BOUNDARY; - } - - /** - * @param {Uint8Array} data - */ - write(data) { - let i = 0; - const length_ = data.length; - let previousIndex = this.index; - let {lookbehind, boundary, boundaryChars, index, state, flags} = this; - const boundaryLength = this.boundary.length; - const boundaryEnd = boundaryLength - 1; - const bufferLength = data.length; - let c; - let cl; - - const mark = name => { - this[name + 'Mark'] = i; - }; - - const clear = name => { - delete this[name + 'Mark']; - }; - - const callback = (callbackSymbol, start, end, ui8a) => { - if (start === undefined || start !== end) { - this[callbackSymbol](ui8a && ui8a.subarray(start, end)); - } - }; - - const dataCallback = (name, clear) => { - const markSymbol = name + 'Mark'; - if (!(markSymbol in this)) { - return; - } - - if (clear) { - callback(name, this[markSymbol], i, data); - delete this[markSymbol]; - } else { - callback(name, this[markSymbol], data.length, data); - this[markSymbol] = 0; - } - }; - - for (i = 0; i < length_; i++) { - c = data[i]; - - switch (state) { - case S.START_BOUNDARY: - if (index === boundary.length - 2) { - if (c === HYPHEN) { - flags |= F.LAST_BOUNDARY; - } else if (c !== CR) { - return; - } - - index++; - break; - } else if (index - 1 === boundary.length - 2) { - if (flags & F.LAST_BOUNDARY && c === HYPHEN) { - state = S.END; - flags = 0; - } else if (!(flags & F.LAST_BOUNDARY) && c === LF) { - index = 0; - callback('onPartBegin'); - state = S.HEADER_FIELD_START; - } else { - return; - } - - break; - } - - if (c !== boundary[index + 2]) { - index = -2; - } - - if (c === boundary[index + 2]) { - index++; - } - - break; - case S.HEADER_FIELD_START: - state = S.HEADER_FIELD; - mark('onHeaderField'); - index = 0; - // falls through - case S.HEADER_FIELD: - if (c === CR) { - clear('onHeaderField'); - state = S.HEADERS_ALMOST_DONE; - break; - } - - index++; - if (c === HYPHEN) { - break; - } - - if (c === COLON) { - if (index === 1) { - // empty header field - return; - } - - dataCallback('onHeaderField', true); - state = S.HEADER_VALUE_START; - break; - } - - cl = lower(c); - if (cl < A || cl > Z) { - return; - } - - break; - case S.HEADER_VALUE_START: - if (c === SPACE) { - break; - } - - mark('onHeaderValue'); - state = S.HEADER_VALUE; - // falls through - case S.HEADER_VALUE: - if (c === CR) { - dataCallback('onHeaderValue', true); - callback('onHeaderEnd'); - state = S.HEADER_VALUE_ALMOST_DONE; - } - - break; - case S.HEADER_VALUE_ALMOST_DONE: - if (c !== LF) { - return; - } - - state = S.HEADER_FIELD_START; - break; - case S.HEADERS_ALMOST_DONE: - if (c !== LF) { - return; - } - - callback('onHeadersEnd'); - state = S.PART_DATA_START; - break; - case S.PART_DATA_START: - state = S.PART_DATA; - mark('onPartData'); - // falls through - case S.PART_DATA: - previousIndex = index; - - if (index === 0) { - // boyer-moore derrived algorithm to safely skip non-boundary data - i += boundaryEnd; - while (i < bufferLength && !(data[i] in boundaryChars)) { - i += boundaryLength; - } - - i -= boundaryEnd; - c = data[i]; - } - - if (index < boundary.length) { - if (boundary[index] === c) { - if (index === 0) { - dataCallback('onPartData', true); - } - - index++; - } else { - index = 0; - } - } else if (index === boundary.length) { - index++; - if (c === CR) { - // CR = part boundary - flags |= F.PART_BOUNDARY; - } else if (c === HYPHEN) { - // HYPHEN = end boundary - flags |= F.LAST_BOUNDARY; - } else { - index = 0; - } - } else if (index - 1 === boundary.length) { - if (flags & F.PART_BOUNDARY) { - index = 0; - if (c === LF) { - // unset the PART_BOUNDARY flag - flags &= ~F.PART_BOUNDARY; - callback('onPartEnd'); - callback('onPartBegin'); - state = S.HEADER_FIELD_START; - break; - } - } else if (flags & F.LAST_BOUNDARY) { - if (c === HYPHEN) { - callback('onPartEnd'); - state = S.END; - flags = 0; - } else { - index = 0; - } - } else { - index = 0; - } - } - - if (index > 0) { - // when matching a possible boundary, keep a lookbehind reference - // in case it turns out to be a false lead - lookbehind[index - 1] = c; - } else if (previousIndex > 0) { - // if our boundary turned out to be rubbish, the captured lookbehind - // belongs to partData - const _lookbehind = new Uint8Array(lookbehind.buffer, lookbehind.byteOffset, lookbehind.byteLength); - callback('onPartData', 0, previousIndex, _lookbehind); - previousIndex = 0; - mark('onPartData'); - - // reconsider the current character even so it interrupted the sequence - // it could be the beginning of a new sequence - i--; - } - - break; - case S.END: - break; - default: - throw new Error(`Unexpected state entered: ${state}`); - } - } - - dataCallback('onHeaderField'); - dataCallback('onHeaderValue'); - dataCallback('onPartData'); - - // Update properties for the next call - this.index = index; - this.state = state; - this.flags = flags; - } - - end() { - if ((this.state === S.HEADER_FIELD_START && this.index === 0) || - (this.state === S.PART_DATA && this.index === this.boundary.length)) { - this.onPartEnd(); - } else if (this.state !== S.END) { - throw new Error('MultipartParser.end(): stream ended unexpectedly'); - } - } -} - -function _fileName(headerValue) { - // matches either a quoted-string or a token (RFC 2616 section 19.5.1) - const m = headerValue.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i); - if (!m) { - return; - } - - const match = m[2] || m[3] || ''; - let filename = match.slice(match.lastIndexOf('\\') + 1); - filename = filename.replace(/%22/g, '"'); - filename = filename.replace(/&#(\d{4});/g, (m, code) => { - return String.fromCharCode(code); - }); - return filename; -} - -async function toFormData(Body, ct) { - if (!/multipart/i.test(ct)) { - throw new TypeError('Failed to fetch'); - } - - const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i); - - if (!m) { - throw new TypeError('no or bad content-type header, no multipart boundary'); - } - - const parser = new MultipartParser(m[1] || m[2]); - - let headerField; - let headerValue; - let entryValue; - let entryName; - let contentType; - let filename; - const entryChunks = []; - const formData = new formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__/* .FormData */ .Ct(); - - const onPartData = ui8a => { - entryValue += decoder.decode(ui8a, {stream: true}); - }; - - const appendToFile = ui8a => { - entryChunks.push(ui8a); - }; - - const appendFileToFormData = () => { - const file = new fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__/* .File */ .$B(entryChunks, filename, {type: contentType}); - formData.append(entryName, file); - }; - - const appendEntryToFormData = () => { - formData.append(entryName, entryValue); - }; - - const decoder = new TextDecoder('utf-8'); - decoder.decode(); - - parser.onPartBegin = function () { - parser.onPartData = onPartData; - parser.onPartEnd = appendEntryToFormData; - - headerField = ''; - headerValue = ''; - entryValue = ''; - entryName = ''; - contentType = ''; - filename = null; - entryChunks.length = 0; - }; - - parser.onHeaderField = function (ui8a) { - headerField += decoder.decode(ui8a, {stream: true}); - }; - - parser.onHeaderValue = function (ui8a) { - headerValue += decoder.decode(ui8a, {stream: true}); - }; - - parser.onHeaderEnd = function () { - headerValue += decoder.decode(); - headerField = headerField.toLowerCase(); - - if (headerField === 'content-disposition') { - // matches either a quoted-string or a token (RFC 2616 section 19.5.1) - const m = headerValue.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i); - - if (m) { - entryName = m[2] || m[3] || ''; - } - - filename = _fileName(headerValue); - - if (filename) { - parser.onPartData = appendToFile; - parser.onPartEnd = appendFileToFormData; - } - } else if (headerField === 'content-type') { - contentType = headerValue; - } - - headerValue = ''; - headerField = ''; - }; - - for await (const chunk of Body) { - parser.write(chunk); - } - - parser.end(); - - return formData; -} - - -/***/ }) - -}; -; \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 68f4299..2ebc9f3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -524,8 +524,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.OidcClient = void 0; -const http_client_1 = __nccwpck_require__(9706); -const auth_1 = __nccwpck_require__(8336); +const http_client_1 = __nccwpck_require__(199); +const auth_1 = __nccwpck_require__(6259); const core_1 = __nccwpck_require__(7954); class OidcClient { static createHttpClient(allowRetry = true, maxRetry = 10) { @@ -994,7 +994,7 @@ exports.toCommandProperties = toCommandProperties; /***/ }), -/***/ 8336: +/***/ 6259: /***/ (function(__unused_webpack_module, exports) { "use strict"; @@ -1082,7 +1082,7 @@ exports.PersonalAccessTokenCredentialHandler = PersonalAccessTokenCredentialHand /***/ }), -/***/ 9706: +/***/ 199: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -1120,7 +1120,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.HttpClient = exports.isHttps = exports.HttpClientResponse = exports.HttpClientError = exports.getProxyUrl = exports.MediaTypes = exports.Headers = exports.HttpCodes = void 0; const http = __importStar(__nccwpck_require__(3685)); const https = __importStar(__nccwpck_require__(5687)); -const pm = __importStar(__nccwpck_require__(531)); +const pm = __importStar(__nccwpck_require__(7153)); const tunnel = __importStar(__nccwpck_require__(8125)); var HttpCodes; (function (HttpCodes) { @@ -1694,7 +1694,7 @@ const lowercaseKeys = (obj) => Object.keys(obj).reduce((c, k) => ((c[k.toLowerCa /***/ }), -/***/ 531: +/***/ 7153: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -1726,6 +1726,10 @@ function checkBypass(reqUrl) { if (!reqUrl.hostname) { return false; } + const reqHost = reqUrl.hostname; + if (isLoopbackAddress(reqHost)) { + return true; + } const noProxy = process.env['no_proxy'] || process.env['NO_PROXY'] || ''; if (!noProxy) { return false; @@ -1751,18 +1755,29 @@ function checkBypass(reqUrl) { .split(',') .map(x => x.trim().toUpperCase()) .filter(x => x)) { - if (upperReqHosts.some(x => x === upperNoProxyItem)) { + if (upperNoProxyItem === '*' || + upperReqHosts.some(x => x === upperNoProxyItem || + x.endsWith(`.${upperNoProxyItem}`) || + (upperNoProxyItem.startsWith('.') && + x.endsWith(`${upperNoProxyItem}`)))) { return true; } } return false; } exports.checkBypass = checkBypass; +function isLoopbackAddress(host) { + const hostLower = host.toLowerCase(); + return (hostLower === 'localhost' || + hostLower.startsWith('127.') || + hostLower.startsWith('[::1]') || + hostLower.startsWith('[0:0:0:0:0:0:0:1]')); +} //# sourceMappingURL=proxy.js.map /***/ }), -/***/ 9608: +/***/ 5048: /***/ ((module, exports, __nccwpck_require__) => { "use strict"; @@ -3185,6 +3200,20 @@ const isDomainOrSubdomain = function isDomainOrSubdomain(destination, original) return orig === dest || orig[orig.length - dest.length - 1] === '.' && orig.endsWith(dest); }; +/** + * isSameProtocol reports whether the two provided URLs use the same protocol. + * + * Both domains must already be in canonical form. + * @param {string|URL} original + * @param {string|URL} destination + */ +const isSameProtocol = function isSameProtocol(destination, original) { + const orig = new URL$1(original).protocol; + const dest = new URL$1(destination).protocol; + + return orig === dest; +}; + /** * Fetch function * @@ -3216,7 +3245,7 @@ function fetch(url, opts) { let error = new AbortError('The user aborted a request.'); reject(error); if (request.body && request.body instanceof Stream.Readable) { - request.body.destroy(error); + destroyStream(request.body, error); } if (!response || !response.body) return; response.body.emit('error', error); @@ -3257,9 +3286,43 @@ function fetch(url, opts) { req.on('error', function (err) { reject(new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err)); + + if (response && response.body) { + destroyStream(response.body, err); + } + finalize(); }); + fixResponseChunkedTransferBadEnding(req, function (err) { + if (signal && signal.aborted) { + return; + } + + if (response && response.body) { + destroyStream(response.body, err); + } + }); + + /* c8 ignore next 18 */ + if (parseInt(process.version.substring(1)) < 14) { + // Before Node.js 14, pipeline() does not fully support async iterators and does not always + // properly handle when the socket close/end events are out of order. + req.on('socket', function (s) { + s.addListener('close', function (hadError) { + // if a data listener is still present we didn't end cleanly + const hasDataListener = s.listenerCount('data') > 0; + + // if end happened before close but the socket didn't emit an error, do it now + if (response && hasDataListener && !hadError && !(signal && signal.aborted)) { + const err = new Error('Premature close'); + err.code = 'ERR_STREAM_PREMATURE_CLOSE'; + response.body.emit('error', err); + } + }); + }); + } + req.on('response', function (res) { clearTimeout(reqTimeout); @@ -3331,7 +3394,7 @@ function fetch(url, opts) { size: request.size }; - if (!isDomainOrSubdomain(request.url, locationURL)) { + if (!isDomainOrSubdomain(request.url, locationURL) || !isSameProtocol(request.url, locationURL)) { for (const name of ['authorization', 'www-authenticate', 'cookie', 'cookie2']) { requestOpts.headers.delete(name); } @@ -3424,6 +3487,13 @@ function fetch(url, opts) { response = new Response(body, response_options); resolve(response); }); + raw.on('end', function () { + // some old IIS servers return zero-length OK deflate responses, so 'data' is never emitted. + if (!response) { + response = new Response(body, response_options); + resolve(response); + } + }); return; } @@ -3443,6 +3513,41 @@ function fetch(url, opts) { writeToStream(req, request); }); } +function fixResponseChunkedTransferBadEnding(request, errorCallback) { + let socket; + + request.on('socket', function (s) { + socket = s; + }); + + request.on('response', function (response) { + const headers = response.headers; + + if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) { + response.once('close', function (hadError) { + // if a data listener is still present we didn't end cleanly + const hasDataListener = socket.listenerCount('data') > 0; + + if (hasDataListener && !hadError) { + const err = new Error('Premature close'); + err.code = 'ERR_STREAM_PREMATURE_CLOSE'; + errorCallback(err); + } + }); + } + }); +} + +function destroyStream(stream, err) { + if (stream.destroy) { + stream.destroy(err); + } else { + // node < 8 + stream.emit('error', err); + stream.end(); + } +} + /** * Redirect code matching * @@ -6735,7 +6840,7 @@ var __webpack_exports__ = {}; const core = __nccwpck_require__(7954); const fs = __nccwpck_require__(7147); const path = __nccwpck_require__(1017); -const fetch = __nccwpck_require__(9608); +const fetch = __nccwpck_require__(5048); function getFilenameFromUrl(url) { const u = new URL(url); @@ -6745,6 +6850,27 @@ function getFilenameFromUrl(url) { return filenameWithArgs.replace(/\?.*/, ""); } +const FetchFailure = Symbol("FetchFailure"); + +async function tryFetch(url, retryTimes) { + let result; + for (let i = 1; i <= retryTimes; i++) { + result = await fetch(url) + .then((x) => x.buffer()) + .catch((err) => { + console.error( + `[${i}/${retryTimes}] Fail to download file ${url}: ${err}` + ); + if (i === retryTimes) { + core.setFailed(`Fail to download file ${url}: ${err}`); + } + return FetchFailure; + }); + if (result !== FetchFailure) return result; + } + return FetchFailure; +} + async function main() { try { const text = core.getInput("url"); @@ -6756,6 +6882,12 @@ async function main() { } else { autoMatch = true; } + const retryTimesValue = core.getInput("retry-times"); + const retryTimes = Number(retryTimesValue); + if (Number.isNaN(retryTimes)) { + core.setFailed(`Invalid value for "retry-times": ${retryTimesValue}`); + return; + } const url = (() => { if (!autoMatch) return text; if (autoMatch) { @@ -6777,13 +6909,8 @@ async function main() { core.setFailed(`Failed to create target directory ${target}: ${e}`); return; } - const body = await fetch(url) - .then((x) => x.buffer()) - .catch((err) => { - core.setFailed(`Fail to download file ${url}: ${err}`); - return undefined; - }); - if (body === undefined) return; + const body = await tryFetch(url, retryTimes); + if (body === FetchFailure) return; console.log("Download completed."); let finalFilename = ""; if (filename) { @@ -6792,7 +6919,9 @@ async function main() { finalFilename = getFilenameFromUrl(url); } if (finalFilename === "") { - core.setFailed("Filename not found. Please indicate it in the URL or set `filename` in the workflow."); + core.setFailed( + "Filename not found. Please indicate it in the URL or set `filename` in the workflow." + ); return; } fs.writeFileSync(path.join(target, finalFilename), body); diff --git a/index.js b/index.js index 1d7548a..bd85433 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,27 @@ function getFilenameFromUrl(url) { return filenameWithArgs.replace(/\?.*/, ""); } +const FetchFailure = Symbol("FetchFailure"); + +async function tryFetch(url, retryTimes) { + let result; + for (let i = 1; i <= retryTimes; i++) { + result = await fetch(url) + .then((x) => x.buffer()) + .catch((err) => { + console.error( + `[${i}/${retryTimes}] Fail to download file ${url}: ${err}` + ); + if (i === retryTimes) { + core.setFailed(`Fail to download file ${url}: ${err}`); + } + return FetchFailure; + }); + if (result !== FetchFailure) return result; + } + return FetchFailure; +} + async function main() { try { const text = core.getInput("url"); @@ -22,6 +43,12 @@ async function main() { } else { autoMatch = true; } + const retryTimesValue = core.getInput("retry-times"); + const retryTimes = Number(retryTimesValue); + if (Number.isNaN(retryTimes)) { + core.setFailed(`Invalid value for "retry-times": ${retryTimesValue}`); + return; + } const url = (() => { if (!autoMatch) return text; if (autoMatch) { @@ -43,13 +70,8 @@ async function main() { core.setFailed(`Failed to create target directory ${target}: ${e}`); return; } - const body = await fetch(url) - .then((x) => x.buffer()) - .catch((err) => { - core.setFailed(`Fail to download file ${url}: ${err}`); - return undefined; - }); - if (body === undefined) return; + const body = await tryFetch(url, retryTimes); + if (body === FetchFailure) return; console.log("Download completed."); let finalFilename = ""; if (filename) {