mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-13 03:10:40 -07:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f69a08399 | ||
|
|
529dd67eeb | ||
|
|
abc058be0f | ||
|
|
521177e3f6 | ||
|
|
75e06eff92 | ||
|
|
0ad2b11ab1 | ||
|
|
97d829116c | ||
|
|
35d5230263 | ||
|
|
94440a0653 | ||
|
|
95ddefc056 | ||
|
|
1b85fdaa9c | ||
|
|
93c38c7c6d | ||
|
|
c7a291b46b | ||
|
|
eb4683ee3f | ||
|
|
f1fc573a65 | ||
|
|
524cd92cfe | ||
|
|
0d9e797889 | ||
|
|
3cd024d652 | ||
|
|
85233e0568 | ||
|
|
02403a1879 |
@@ -784,6 +784,33 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "MorielHarush",
|
||||
"name": "MorielHarush",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/93482738?v=4",
|
||||
"profile": "https://github.com/MorielHarush",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "peaktwilight",
|
||||
"name": "Peak Twilight",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/77903714?v=4",
|
||||
"profile": "https://doruk.ch",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "joecottam",
|
||||
"name": "Joe Cottam",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44173086?v=4",
|
||||
"profile": "https://github.com/joecottam",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Architecture overview for liquidjs internals
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Async/sync duality via generators
|
||||
|
||||
All core logic is written once as a `Generator` function (`function *`). Use `yield` where you'd normally `await` a potentially async value.
|
||||
|
||||
- `toPromise(generator)` drives it **asynchronously** — awaits yielded promises.
|
||||
- `toValueSync(generator)` drives it **synchronously** — passes yielded values through as-is.
|
||||
|
||||
Never duplicate logic into separate async and sync methods. A single generator serves both paths.
|
||||
|
||||
When wrapping an async+sync function pair (e.g. `contains`/`containsSync`, `exists`/`existsSync`, `readFile`/`readFileSync`), use `toLiquidAsync(asyncFn, syncFn?)` which returns a `LiquidAsync<F>` — one function that picks the sync or async implementation based on a leading `sync: boolean` arg. Then `yield` the result inside a generator to let the driver handle it in both modes.
|
||||
|
||||
See `src/util/async.ts`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Project conventions for liquidjs
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
- Keep edits minimal: change only what the task requires, match existing style.
|
||||
@@ -4,6 +4,11 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
## [10.25.3](https://github.com/harttle/liquidjs/compare/v10.25.2...v10.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* precise memoryLimit for string replace ([abc058b](https://github.com/harttle/liquidjs/commit/abc058be0f33d6372cd2216f4945183167abeb25))
|
||||
* use realpath for fs.contains ([#867](https://github.com/harttle/liquidjs/issues/867)) ([529dd67](https://github.com/harttle/liquidjs/commit/529dd67eeb6b125637623d6a723601f0938d3613))
|
||||
|
||||
## [10.25.2](https://github.com/harttle/liquidjs/compare/v10.25.1...v10.25.2) (2026-03-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle undefined replacement argument in replace filter ([#864](https://github.com/harttle/liquidjs/issues/864)) ([0ad2b11](https://github.com/harttle/liquidjs/commit/0ad2b11ab15e7da608a9ef936b2a00a6a6517038))
|
||||
|
||||
## [10.25.1](https://github.com/harttle/liquidjs/compare/v10.25.0...v10.25.1) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mem limiter for invalid ranges ([95ddefc](https://github.com/harttle/liquidjs/commit/95ddefc056a11a44d9e753fd47a39db2c241e578))
|
||||
* treat args for replace_first as literal ([35d5230](https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d))
|
||||
|
||||
# [10.25.0](https://github.com/harttle/liquidjs/compare/v10.24.0...v10.25.0) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path traversal vulnerability, [#851](https://github.com/harttle/liquidjs/issues/851) ([#855](https://github.com/harttle/liquidjs/issues/855)) ([3cd024d](https://github.com/harttle/liquidjs/commit/3cd024d652dc883c46307581e979fe32302adbac))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* export error types, resolving [#837](https://github.com/harttle/liquidjs/issues/837) ([#840](https://github.com/harttle/liquidjs/issues/840)) ([71aa1b1](https://github.com/harttle/liquidjs/commit/71aa1b1998a3a66e536af67c6ea8947a28616eaf))
|
||||
|
||||
# [10.24.0](https://github.com/harttle/liquidjs/compare/v10.23.0...v10.24.0) (2025-10-27)
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ If you personally love LiquidJS or it's benefiting your business, please conside
|
||||
<a href="https://customer.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/1152079?v=4&s=100" height="80" style="vertical-align: middle;" alt="Customer IO" title="Customer IO"/></a>
|
||||
<a href="https://syntax.fm/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/130389858?v=4&s=100" height="80" style="vertical-align: middle;" alt="Syntax Podcast" title="Syntax Podcast"/></a>
|
||||
<br/>
|
||||
<a href="https://www.lambdatest.com/?utm_source=liquidjs&utm_medium=sponsor" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://www.lambdatest.com/blue-logo.png" width="240" style="vertical-align: middle;" alt="LambdaTest" title="LambdaTest"/></a>
|
||||
<a href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=liquidjs" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/27130435?s=200&v=4" width="80" style="vertical-align: middle;" alt="TestMu AI" title="TestMu AI"/></a>
|
||||
<a href="https://chudovo.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/Chudovo/avatar/256.png?height=100" width="160" style="vertical-align: middle;background: white;padding: 8px 16px;" alt="Chudovo" title="Chudovo"/></a>
|
||||
<a href="https://dailycontributors.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/dailycontributors/3c2e057/logo/256.png?height=50&width=100" width="120" style="vertical-align: middle;" alt="Dailycontributors" title="Dailycontributors"/></a>
|
||||
<a href="https://www.pakstyle.pk/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/pakstyle/2b81605/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="PakStyle.pk" title="PakStyle.pk"/></a>
|
||||
@@ -217,6 +217,9 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rongjiecomputer"><img src="https://avatars.githubusercontent.com/u/13115060?v=4?s=100" width="100px;" alt="Loo Rong Jie"/><br /><sub><b>Loo Rong Jie</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=rongjiecomputer" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/MorielHarush"><img src="https://avatars.githubusercontent.com/u/93482738?v=4?s=100" width="100px;" alt="MorielHarush"/><br /><sub><b>MorielHarush</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=MorielHarush" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://doruk.ch"><img src="https://avatars.githubusercontent.com/u/77903714?v=4?s=100" width="100px;" alt="Peak Twilight"/><br /><sub><b>Peak Twilight</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=peaktwilight" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/joecottam"><img src="https://avatars.githubusercontent.com/u/44173086?v=4?s=100" width="100px;" alt="Joe Cottam"/><br /><sub><b>Joe Cottam</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=joecottam" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Only the latest major version is supported with security updates. It can be chan
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please contact yangjvn@126.com to report a vulnerability or change request.
|
||||
Please contact harttleharttle@gmail.com to report a vulnerability or change request.
|
||||
|
||||
- If the vulnerability in question affects common use cases, it will be treated as a bug and fixed very soon (typically within 1 week).
|
||||
- Otherwise, it'll be scheduled in the same priority of feature request (which is lower than bugs).
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "echo not implemented",
|
||||
"start": "http-server -c-1 "
|
||||
},
|
||||
"author": "harttle <yangjvn@126.com>",
|
||||
"author": "harttle <harttleharttle@gmail.com>",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"http-server": "^0.11.1",
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ const engine = new Liquid({
|
||||
// layout files for `{% layout %}`
|
||||
layouts: process.cwd() + '/layouts',
|
||||
// partial files for `{% include %}` and `{% render %}`
|
||||
partials: process.cwd() + '/partials'
|
||||
partials: [process.cwd() + '/partials', 'node_modules']
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm start | grep 'LiquidJS Demo'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
set -x
|
||||
set -e
|
||||
|
||||
LOG_FILE=$(mktemp)
|
||||
npm start > $LOG_FILE 2>&1 &
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm start | grep 'NodeJS Demo for LiquidJS'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm start | grep '\[11:8] {{ todo }}'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm run build && npm start | grep 'TypeScript Demo for LiquidJS'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm run build
|
||||
npm start | grep 'Webpack Demo for LiquidJS'
|
||||
|
||||
@@ -24,6 +24,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
|
||||
|
||||
* Truthy and Falsy. All values except `undefined`, `null`, `false` are truthy, whereas in Ruby Liquid all except `nil` and `false` are truthy. See [#26][#26].
|
||||
* Number. In JavaScript we cannot distinguish or convert between `float` and `integer`, see [#59][#59]. And when applied `size` filter, numbers always return 0, which is 8 for integer in ruby, cause they do not have a `length` property.
|
||||
* Stringify: We've aligned string coercion for primitive types. While some differences remain; for example, in Shopify/liquid, `strip` returns the "inspected" string of an input array, whereas in LiquidJS, the `strip` filter simply stringifies the input array [#852][#852].
|
||||
* [.to_liquid()](https://github.com/Shopify/liquid/wiki/Introduction-to-Drops) is replaced by `.toLiquid()`
|
||||
* [.to_s()](https://www.rubydoc.info/gems/liquid/Liquid/Drop) is replaced by JavaScript `.toString()`
|
||||
* Iteration order for objects. The iteration order of JavaScript objects, and thus LiquidJS objects, is a combination of the insertion order for string keys, and ascending order for number-like keys, while the iteration order of Ruby Hash is simply the insertion order.
|
||||
@@ -47,6 +48,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
|
||||
[#236]: https://github.com/harttle/liquidjs/issues/236
|
||||
[#414]: https://github.com/harttle/liquidjs/discussions/414
|
||||
[#485]: https://github.com/harttle/liquidjs/discussions/485
|
||||
[#852]: https://github.com/harttle/liquidjs/discussions/852
|
||||
[sort]: https://liquidjs.com/filters/sort.html
|
||||
[stable-sort]: https://v8.dev/features/stable-sort
|
||||
[plugins]: ./plugins.html#Plugin-List
|
||||
|
||||
@@ -45,26 +45,20 @@ It can be a string-typed path (see above example), or a list of root directories
|
||||
|
||||
```javascript
|
||||
var engine = new Liquid({
|
||||
root: ['views/', 'views/partials/'],
|
||||
root: ['views/'],
|
||||
partials: ['views/partials/'],
|
||||
layouts: ['views/layouts/'],
|
||||
extname: '.liquid'
|
||||
});
|
||||
```
|
||||
|
||||
{% note tip Relative Paths %}Relative paths in <code>root</code> will be resolved against <code>cwd()</code>.{% endnote %}
|
||||
|
||||
When `{% raw %}{% render "foo" %}{% endraw %}` is rendered or `liquid.renderFile('foo')` is called, the following files will be looked up and the first existing file will be used:
|
||||
- When `parse()`, `render()` functions are called, for example `liquid.renderFile('foo')`, templates under `root` will be looked up.
|
||||
- When a partial is requested, for example `{% raw %}{% render "foo" %}{% endraw %}`, templates under `partials` will be looked up.
|
||||
- When a layout is requested, for example `{% raw %}{% layout "foo" %}{% endraw %}`, templates under `layouts` will be looked up.
|
||||
|
||||
- `cwd()`/views/foo.liquid
|
||||
- `cwd()`/views/partials/foo.liquid
|
||||
|
||||
If none of the above files exists, an `ENOENT` error will be thrown. Here's a demo for Node.js: [demo/nodejs](https://github.com/harttle/liquidjs/tree/master/demo/nodejs).
|
||||
|
||||
When LiquidJS is used in browser, say current location is <https://example.com/bar/index.html>, only the first `root` will be used and the file to be fetched is:
|
||||
|
||||
- <https://example.com/bar/foo.liquid>
|
||||
|
||||
If fetch fails, a 404/500 error or network failures for example, an `ENOENT` error will be thrown.
|
||||
Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser).
|
||||
When LiquidJS is used in browser, the paths will be resolved based on current location. Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser).
|
||||
|
||||
## Abstract File System
|
||||
|
||||
@@ -98,7 +92,7 @@ var engine = new Liquid({
|
||||
});
|
||||
```
|
||||
|
||||
{% note warn Path Traversal Vulnerability %}The default value of <code>contains()</code> always returns true. That means when specifying an abstract file system, you'll need to provide a proper <code>contains()</code> to avoid expose such vulnerabilities.{% endnote %}
|
||||
{% note warn Path Traversal Vulnerability %}The built-in Node <code>fs</code> implements <code>contains()</code> with realpath so templates cannot escape the root via symlinks. The browser bundle omits <code>contains</code> (loader treats paths as allowed). For a custom abstract <code>fs</code>, implement <code>contains</code> unless every resolved path is trusted.{% endnote %}
|
||||
|
||||
## In-memory Template
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
|
||||
|
||||
* 真和假。在 LiquidJS 中 `undefined`, `null`, `false` 是假,之外的都是真;在 Ruby 中 `nil` 和 `false` 是假,其他都是真。见 [#26][#26]。
|
||||
* 数字。JavaScript 不区分浮点数和整数,因此缺失一部分整数算术,见 [#59][#59]。此外 `size` 过滤器作用于数字时总是返回零,而不是 Ruby 中的浮点数或整数的内存大小。
|
||||
* 输出字符串。基本类型的输出已经和 Shopify/liquid 对齐,但是仍然存在一些区别。比如在 Shopify/liquid 中 `strip` 会返回 inspect 字符串,但 LiquidJS `strip` 只是简单地把输入转换为字符串 [#852][#852]。
|
||||
* Drop 中的 [.to_liquid()](https://github.com/Shopify/liquid/wiki/Introduction-to-Drops) 替换为 `.toLiquid()`。
|
||||
* 数据的 [.to_s()](https://www.rubydoc.info/gems/liquid/Liquid/Drop) 替换为 `.toString()`。
|
||||
* 对象的迭代顺序。JavaScript 对象的迭代顺序是插入顺序和数字键递增顺序的组合,但 Ruby Hash 中只是插入顺序(JavaScript 字面量 Object 和 Ruby 字面量 Hash 的插入顺序解释也不同)。
|
||||
@@ -46,6 +47,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
|
||||
[#236]: https://github.com/harttle/liquidjs/issues/236
|
||||
[#414]: https://github.com/harttle/liquidjs/discussions/414
|
||||
[#485]: https://github.com/harttle/liquidjs/discussions/485
|
||||
[#852]: https://github.com/harttle/liquidjs/discussions/852
|
||||
[sort]: https://liquidjs.com/filters/sort.html
|
||||
[stable-sort]: https://v8.dev/features/stable-sort
|
||||
[plugins]: ./plugins.html#插件列表
|
||||
|
||||
Generated
+3319
-1902
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liquidjs",
|
||||
"version": "10.24.0",
|
||||
"version": "10.25.3",
|
||||
"sideEffects": false,
|
||||
"description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.",
|
||||
"main": "dist/liquid.node.js",
|
||||
@@ -68,7 +68,7 @@
|
||||
"@semantic-release/changelog": "^6.0.2",
|
||||
"@semantic-release/commit-analyzer": "^9.0.2",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/npm": "^9.0.2",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^10.0.3",
|
||||
"@types/benchmark": "^1.0.31",
|
||||
"@types/express": "^4.17.2",
|
||||
@@ -100,7 +100,7 @@
|
||||
"rollup-plugin-typescript2": "^0.31.1",
|
||||
"rollup-plugin-uglify": "^6.0.4",
|
||||
"rollup-plugin-version-injector": "^1.3.3",
|
||||
"semantic-release": "^19.0.3",
|
||||
"semantic-release": "^25.0.3",
|
||||
"sinon": "^15.0.2",
|
||||
"supertest": "^3.4.2",
|
||||
"ts-jest": "^29.0.5",
|
||||
@@ -151,6 +151,9 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"provenance": true
|
||||
},
|
||||
"nyc": {
|
||||
"extension": [
|
||||
".ts"
|
||||
|
||||
+47
-25
@@ -11,7 +11,7 @@
|
||||
// Hiragana (Japanese): \u3040-\u309F
|
||||
// Hangul (Korean): \uAC00-\uD7AF
|
||||
import { FilterImpl } from '../template'
|
||||
import { assert, escapeRegExp, stringify } from '../util'
|
||||
import { assert, stringify } from '../util'
|
||||
|
||||
const rCJKWord = /[\u4E00-\u9FFF\uF900-\uFAFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/gu
|
||||
|
||||
@@ -38,10 +38,14 @@ export function lstrip (this: FilterImpl, v: string, chars?: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
if (chars) {
|
||||
chars = escapeRegExp(stringify(chars))
|
||||
return str.replace(new RegExp(`^[${chars}]+`, 'g'), '')
|
||||
chars = stringify(chars)
|
||||
this.context.memoryLimit.use(chars.length)
|
||||
for (let i = 0, set = new Set(chars); i < str.length; i++) {
|
||||
if (!set.has(str[i])) return str.slice(i)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return str.replace(/^\s+/, '')
|
||||
return str.trimStart()
|
||||
}
|
||||
|
||||
export function downcase (this: FilterImpl, v: string) {
|
||||
@@ -58,20 +62,22 @@ export function upcase (this: FilterImpl, v: string) {
|
||||
|
||||
export function remove (this: FilterImpl, v: string, arg: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.split(stringify(arg)).join('')
|
||||
arg = stringify(arg)
|
||||
this.context.memoryLimit.use(str.length + arg.length)
|
||||
return str.split(arg).join('')
|
||||
}
|
||||
|
||||
export function remove_first (this: FilterImpl, v: string, l: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.replace(stringify(l), '')
|
||||
l = stringify(l)
|
||||
this.context.memoryLimit.use(str.length + l.length)
|
||||
return str.replace(l, '')
|
||||
}
|
||||
|
||||
export function remove_last (this: FilterImpl, v: string, l: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
const pattern = stringify(l)
|
||||
this.context.memoryLimit.use(str.length + pattern.length)
|
||||
const index = str.lastIndexOf(pattern)
|
||||
if (index === -1) return str
|
||||
return str.substring(0, index) + str.substring(index + pattern.length)
|
||||
@@ -81,10 +87,14 @@ export function rstrip (this: FilterImpl, str: string, chars?: string) {
|
||||
str = stringify(str)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
if (chars) {
|
||||
chars = escapeRegExp(stringify(chars))
|
||||
return str.replace(new RegExp(`[${chars}]+$`, 'g'), '')
|
||||
chars = stringify(chars)
|
||||
this.context.memoryLimit.use(chars.length)
|
||||
for (let i = str.length - 1, set = new Set(chars); i >= 0; i--) {
|
||||
if (!set.has(str[i])) return str.slice(0, i + 1)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return str.replace(/\s+$/, '')
|
||||
return str.trimEnd()
|
||||
}
|
||||
|
||||
export function split (this: FilterImpl, v: string, arg: string) {
|
||||
@@ -101,10 +111,13 @@ export function strip (this: FilterImpl, v: string, chars?: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
if (chars) {
|
||||
chars = escapeRegExp(stringify(chars))
|
||||
return str
|
||||
.replace(new RegExp(`^[${chars}]+`, 'g'), '')
|
||||
.replace(new RegExp(`[${chars}]+$`, 'g'), '')
|
||||
const set = new Set(stringify(chars))
|
||||
this.context.memoryLimit.use(set.size)
|
||||
let i = 0
|
||||
let j = str.length - 1
|
||||
while (set.has(str[i])) i++
|
||||
while (j >= i && set.has(str[j])) j--
|
||||
return str.slice(i, j + 1)
|
||||
}
|
||||
return str.trim()
|
||||
}
|
||||
@@ -123,36 +136,44 @@ export function capitalize (this: FilterImpl, str: string) {
|
||||
|
||||
export function replace (this: FilterImpl, v: string, pattern: string, replacement: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.split(stringify(pattern)).join(replacement)
|
||||
pattern = stringify(pattern)
|
||||
replacement = stringify(replacement)
|
||||
const parts = str.split(pattern)
|
||||
const outputSize = str.length + (parts.length - 1) * (replacement.length - pattern.length)
|
||||
this.context.memoryLimit.use(outputSize)
|
||||
return parts.join(replacement)
|
||||
}
|
||||
|
||||
export function replace_first (this: FilterImpl, v: string, arg1: string, arg2: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.replace(stringify(arg1), arg2)
|
||||
arg1 = stringify(arg1)
|
||||
arg2 = stringify(arg2)
|
||||
this.context.memoryLimit.use(str.length + arg1.length + arg2.length)
|
||||
return str.replace(arg1, () => arg2)
|
||||
}
|
||||
|
||||
export function replace_last (this: FilterImpl, v: string, arg1: string, arg2: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
const pattern = stringify(arg1)
|
||||
const replacement = stringify(arg2)
|
||||
this.context.memoryLimit.use(str.length + pattern.length + replacement.length)
|
||||
const index = str.lastIndexOf(pattern)
|
||||
if (index === -1) return str
|
||||
const replacement = stringify(arg2)
|
||||
return str.substring(0, index) + replacement + str.substring(index + pattern.length)
|
||||
}
|
||||
|
||||
export function truncate (this: FilterImpl, v: string, l = 50, o = '...') {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
o = stringify(o)
|
||||
this.context.memoryLimit.use(str.length + o.length)
|
||||
if (str.length <= l) return v
|
||||
return str.substring(0, l - o.length) + o
|
||||
}
|
||||
|
||||
export function truncatewords (this: FilterImpl, v: string, words = 15, o = '...') {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
o = stringify(o)
|
||||
this.context.memoryLimit.use(str.length + o.length)
|
||||
const arr = str.split(/\s+/)
|
||||
if (words <= 0) words = 1
|
||||
let ret = arr.slice(0, words).join(' ')
|
||||
@@ -187,7 +208,8 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' |
|
||||
}
|
||||
|
||||
export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') {
|
||||
this.context.memoryLimit.use(array.length)
|
||||
connector = stringify(connector)
|
||||
this.context.memoryLimit.use(array.length + connector.length)
|
||||
switch (array.length) {
|
||||
case 0:
|
||||
return ''
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as fs from './fs-impl'
|
||||
import * as path from 'path'
|
||||
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const { join } = path
|
||||
|
||||
describe('fs-impl', function () {
|
||||
describe('.resolve()', function () {
|
||||
@@ -50,4 +54,36 @@ describe('fs-impl', function () {
|
||||
expect(content).toContain('should read content if exists')
|
||||
})
|
||||
})
|
||||
describe('.contains()', () => {
|
||||
const canSymlink = process.platform !== 'win32'
|
||||
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
|
||||
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
|
||||
writeFileSync(outside, 'x')
|
||||
const link = join(root, 'link.liquid')
|
||||
symlinkSync(outside, link)
|
||||
try {
|
||||
expect(await fs.contains(root, link)).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
describe('.containsSync()', () => {
|
||||
const canSymlink = process.platform !== 'win32'
|
||||
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
|
||||
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
|
||||
writeFileSync(outside, 'x')
|
||||
const link = join(root, 'link.liquid')
|
||||
symlinkSync(outside, link)
|
||||
try {
|
||||
expect(fs.containsSync(root, link)).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+22
-5
@@ -1,6 +1,6 @@
|
||||
import { promisify } from '../util'
|
||||
import { sep, resolve as nodeResolve, extname, dirname as nodeDirname } from 'path'
|
||||
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync } from 'fs'
|
||||
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync, realpath, realpathSync } from 'fs'
|
||||
import { requireResolve } from './node-require'
|
||||
|
||||
type NodeReadFile = (file: string, encoding: string, cb: ((err: Error | null, result: string) => void)) => void
|
||||
@@ -41,10 +41,27 @@ export function fallback (file: string) {
|
||||
export function dirname (filepath: string) {
|
||||
return nodeDirname(filepath)
|
||||
}
|
||||
export function contains (root: string, file: string) {
|
||||
root = nodeResolve(root)
|
||||
root = root.endsWith(sep) ? root : root + sep
|
||||
return file.startsWith(root)
|
||||
const realpathAsync = promisify(realpath)
|
||||
|
||||
export async function contains (root: string, file: string) {
|
||||
try {
|
||||
const realRoot = await realpathAsync(root)
|
||||
const realFile = await realpathAsync(file)
|
||||
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
|
||||
return realFile.startsWith(prefix)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
export function containsSync (root: string, file: string) {
|
||||
try {
|
||||
const realRoot = realpathSync(root)
|
||||
const realFile = realpathSync(file)
|
||||
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
|
||||
return realFile.startsWith(prefix)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { sep } from 'path'
|
||||
|
||||
+4
-2
@@ -9,8 +9,10 @@ export interface FS {
|
||||
readFileSync: (filepath: string) => string;
|
||||
/** resolve a file against directory, for given `ext` option */
|
||||
resolve: (dir: string, file: string, ext: string) => string;
|
||||
/** check if file is contained in `root`, always return `true` by default. Warning: not setting this could expose path traversal vulnerabilities. */
|
||||
contains?: (root: string, file: string) => boolean;
|
||||
/** check if file is contained in `root`. Node default fs uses realpath; if omitted, loader assumes contained. */
|
||||
contains?: (root: string, file: string) => Promise<boolean>;
|
||||
/** sync check if file is contained in `root`, allows both renderSync and render. */
|
||||
containsSync?: (root: string, file: string) => boolean;
|
||||
/** defaults to "/" */
|
||||
sep?: string;
|
||||
/** required for relative path resolving */
|
||||
|
||||
+23
-20
@@ -1,31 +1,34 @@
|
||||
import * as fs from './fs-impl'
|
||||
import { Loader } from './loader'
|
||||
import { resolve } from 'path'
|
||||
import { Loader, LookupType } from './loader'
|
||||
import { toValueSync } from '../util/async'
|
||||
|
||||
describe('fs/loader', function () {
|
||||
describe('.candidates()', function () {
|
||||
it('should resolve relatively', async function () {
|
||||
it('should resolve relatively', function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current', true)]
|
||||
expect(candidates).toContain('/root/foo/bar')
|
||||
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current')]
|
||||
expect(candidates).toContain(resolve('/root/foo/bar'))
|
||||
})
|
||||
it('should not include out of root candidates', async function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
|
||||
expect(candidates).toHaveLength(0)
|
||||
})
|
||||
describe('.lookup()', function () {
|
||||
it('should not include out of root candidates', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
|
||||
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
|
||||
expect(() => toValueSync(loader.lookup('../foo/bar', LookupType.Partials, true, '/root/current')))
|
||||
.toThrow(/ENOENT/)
|
||||
})
|
||||
it('should treat root as a terminated path', async function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('../root-dir/bar', ['/root'], '/root/current', true)]
|
||||
expect(candidates).toHaveLength(0)
|
||||
it('should treat root as a terminated path', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
|
||||
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
|
||||
expect(() => toValueSync(loader.lookup('../root-dir/bar', LookupType.Partials, true, '/root/current')))
|
||||
.toThrow(/ENOENT/)
|
||||
})
|
||||
it('should default `.contains()` to () => true', async function () {
|
||||
const customFs = {
|
||||
...fs,
|
||||
contains: undefined
|
||||
}
|
||||
const loader = new Loader({ relativeReference: true, fs: customFs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
|
||||
expect(candidates).toContain('/foo/bar')
|
||||
it('should use permissive contains when fs.contains is omitted', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true, contains: undefined, containsSync: undefined }
|
||||
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
|
||||
const result = toValueSync(loader.lookup('./foo/bar', LookupType.Partials, true, '/root/current'))
|
||||
expect(result).toBe(resolve('/root/foo/bar'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+29
-19
@@ -1,5 +1,5 @@
|
||||
import { FS } from './fs'
|
||||
import { assert, escapeRegex } from '../util'
|
||||
import { assert, LiquidAsync, toLiquidAsync } from '../util'
|
||||
|
||||
export interface LoaderOptions {
|
||||
fs: FS;
|
||||
@@ -17,48 +17,58 @@ export enum LookupType {
|
||||
export class Loader {
|
||||
public shouldLoadRelative: (referencedFile: string) => boolean
|
||||
private options: LoaderOptions
|
||||
private contains: (root: string, file: string) => boolean
|
||||
private contains: LiquidAsync<NonNullable<FS['containsSync']>>
|
||||
private exists: LiquidAsync<FS['existsSync']>
|
||||
|
||||
constructor (options: LoaderOptions) {
|
||||
this.options = options
|
||||
if (options.relativeReference) {
|
||||
const sep = options.fs.sep
|
||||
assert(sep, '`fs.sep` is required for relative reference')
|
||||
const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|'))
|
||||
this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile)
|
||||
const prefixes = ['.' + sep, '..' + sep, './', '../']
|
||||
this.shouldLoadRelative = (referencedFile: string) => prefixes.some(prefix => referencedFile.startsWith(prefix))
|
||||
} else {
|
||||
this.shouldLoadRelative = (_referencedFile: string) => false
|
||||
}
|
||||
this.contains = this.options.fs.contains || (() => true)
|
||||
const fs = options.fs
|
||||
this.contains = toLiquidAsync(
|
||||
fs.contains?.bind(fs) || (async () => true),
|
||||
fs.containsSync?.bind(fs) || (() => true)
|
||||
)
|
||||
this.exists = toLiquidAsync(
|
||||
fs.exists?.bind(fs) || (async () => false),
|
||||
fs.existsSync?.bind(fs)
|
||||
)
|
||||
}
|
||||
|
||||
public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string): Generator<unknown, string, string> {
|
||||
const { fs } = this.options
|
||||
const dirs = this.options[type]
|
||||
for (const filepath of this.candidates(file, dirs, currentFile, type !== LookupType.Root)) {
|
||||
if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
|
||||
const enforceRoot = type !== LookupType.Root
|
||||
for (const filepath of this.candidates(file, dirs, currentFile)) {
|
||||
if (enforceRoot) {
|
||||
let allowed = false
|
||||
for (const dir of dirs) {
|
||||
if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break }
|
||||
}
|
||||
if (!allowed) continue
|
||||
}
|
||||
if (yield this.exists(!!sync, filepath)) return filepath
|
||||
}
|
||||
throw this.lookupError(file, dirs)
|
||||
}
|
||||
|
||||
public * candidates (file: string, dirs: string[], currentFile?: string, enforceRoot?: boolean) {
|
||||
public * candidates (file: string, dirs: string[], currentFile?: string) {
|
||||
const { fs, extname } = this.options
|
||||
|
||||
if (this.shouldLoadRelative(file) && currentFile) {
|
||||
const referenced = fs.resolve(this.dirname(currentFile), file, extname)
|
||||
for (const dir of dirs) {
|
||||
if (!enforceRoot || this.contains(dir, referenced)) {
|
||||
// the relatively referenced file is within one of root dirs
|
||||
yield referenced
|
||||
break
|
||||
}
|
||||
}
|
||||
yield referenced
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
const referenced = fs.resolve(dir, file, extname)
|
||||
if (!enforceRoot || this.contains(dir, referenced)) {
|
||||
yield referenced
|
||||
}
|
||||
yield referenced
|
||||
}
|
||||
|
||||
if (fs.fallback !== undefined) {
|
||||
const filepath = fs.fallback(file)
|
||||
if (filepath !== undefined) yield filepath
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError } from '../util'
|
||||
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError, toLiquidAsync, LiquidAsync } from '../util'
|
||||
import { Tokenizer } from './tokenizer'
|
||||
import { ParseStream } from './parse-stream'
|
||||
import { TopLevelToken, OutputToken } from '../tokens'
|
||||
@@ -16,6 +16,7 @@ export class Parser {
|
||||
private cache?: LiquidCache
|
||||
private loader: Loader
|
||||
private parseLimit: Limiter
|
||||
private readFile: LiquidAsync<FS['readFileSync']>
|
||||
|
||||
public constructor (liquid: Liquid) {
|
||||
this.liquid = liquid
|
||||
@@ -24,6 +25,10 @@ export class Parser {
|
||||
this.parseFile = this.cache ? this._parseFileCached : this._parseFile
|
||||
this.loader = new Loader(this.liquid.options)
|
||||
this.parseLimit = new Limiter('parse length', liquid.options.parseLimit)
|
||||
this.readFile = toLiquidAsync(
|
||||
this.fs.readFile?.bind(this.fs) || (async () => { throw new Error('readFile not implemented') }),
|
||||
this.fs.readFileSync?.bind(this.fs)
|
||||
)
|
||||
}
|
||||
public parse (html: string, filepath?: string): Template[] {
|
||||
html = String(html)
|
||||
@@ -82,6 +87,6 @@ export class Parser {
|
||||
}
|
||||
private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string): Generator<unknown, Template[], string> {
|
||||
const filepath = yield this.loader.lookup(file, type, sync, currentFile)
|
||||
return this.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
|
||||
return this.parse(yield this.readFile(!!sync, filepath), filepath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { isPromise, isIterator } from './underscore'
|
||||
|
||||
export type LiquidAsync<F extends (...args: any[]) => any> =
|
||||
(sync: boolean, ...args: Parameters<F>) => ReturnType<F> | Promise<ReturnType<F>>
|
||||
|
||||
export function toLiquidAsync<F extends (...args: any[]) => any> (
|
||||
asyncFn: (...args: Parameters<F>) => Promise<ReturnType<F>>,
|
||||
syncFn?: F
|
||||
): LiquidAsync<F> {
|
||||
const syncImpl = syncFn || asyncFn as any
|
||||
return (sync: boolean, ...args: any[]) => {
|
||||
return sync ? syncImpl(...args as Parameters<F>) : asyncFn(...args as Parameters<F>)
|
||||
}
|
||||
}
|
||||
|
||||
// convert an async iterator to a Promise
|
||||
export async function toPromise<T> (val: Generator<unknown, T, unknown> | Promise<T> | T): Promise<T> {
|
||||
if (!isIterator(val)) return val
|
||||
|
||||
+7
-5
@@ -9,12 +9,14 @@ export class Limiter {
|
||||
this.limit = limit
|
||||
}
|
||||
use (count: number) {
|
||||
count = +count || 0
|
||||
assert(this.base + count <= this.limit, this.message)
|
||||
this.base += count
|
||||
if (+count > 0) {
|
||||
assert(this.base + +count <= this.limit, this.message)
|
||||
this.base += +count
|
||||
}
|
||||
}
|
||||
check (count: number) {
|
||||
count = +count || 0
|
||||
assert(count <= this.limit, this.message)
|
||||
if (+count > 0) {
|
||||
assert(+count <= this.limit, this.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ export function isIterator (val: any): val is IterableIterator<any> {
|
||||
return val && isFunction(val.next) && isFunction(val.throw) && isFunction(val.return)
|
||||
}
|
||||
|
||||
export function escapeRegex (str: string) {
|
||||
return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function promisify<T1, T2> (fn: (arg1: T1, cb: (err: Error | null, result: T2) => void) => void): (arg1: T1) => Promise<T2>;
|
||||
export function promisify<T1, T2, T3> (fn: (arg1: T1, arg2: T2, cb: (err: Error | null, result: T3) => void) => void): (arg1: T1, arg2: T2) => Promise<T3>;
|
||||
export function promisify (fn: any) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { resolve as resolvePath } from 'path'
|
||||
const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid
|
||||
|
||||
describe('Issues', function () {
|
||||
@@ -173,6 +175,24 @@ describe('Issues', function () {
|
||||
const html = await engine.render(tpl, { my_variable: 'foo' })
|
||||
expect(html).toBe('CONTENT for /tmp/prefix/foo-bar/suffix')
|
||||
})
|
||||
it('should prevent path traversal in dynamic include with restricted root, #851', () => {
|
||||
const projectRoot = resolvePath(__dirname, '../..')
|
||||
const poc = `
|
||||
const { Liquid } = require('./dist/liquid.node.js');
|
||||
const e = new Liquid({ root: ['/tmp'], partials: ['/tmp'], dynamicPartials: true });
|
||||
e.parseAndRender('{% include page %}', { page: '../../../etc/passwd' })
|
||||
.then(() => { console.log('OK'); })
|
||||
.catch(err => { console.error('ERR:' + err.message); process.exit(1); });
|
||||
`
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['-e', poc],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('Failed to lookup')
|
||||
})
|
||||
it('Implement liquid/echo tags #428', () => {
|
||||
const template = `{%- liquid
|
||||
for value in array
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Liquid } from '../..'
|
||||
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
describe('.parseAndRender()', function () {
|
||||
var engine: Liquid, strictEngine: Liquid
|
||||
@@ -57,4 +60,26 @@ describe('.parseAndRender()', function () {
|
||||
const html = await engine.parseAndRender(src)
|
||||
expect(html).toBe('true')
|
||||
})
|
||||
const canSymlink = process.platform !== 'win32'
|
||||
;(canSymlink ? describe : describe.skip)('symlink outside root', function () {
|
||||
let root: string, secret: string
|
||||
beforeAll(function () {
|
||||
root = mkdtempSync(join(tmpdir(), 'liquid-e2e-root-'))
|
||||
secret = join(tmpdir(), `liquid-e2e-secret-${Date.now()}.liquid`)
|
||||
writeFileSync(secret, 'SECRET_OUTSIDE')
|
||||
symlinkSync(secret, join(root, 'link.liquid'))
|
||||
})
|
||||
afterAll(function () {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(secret, { force: true })
|
||||
})
|
||||
it('should not render a symlink partial whose target is outside root', async function () {
|
||||
const e = new Liquid({ root: [root], extname: '.liquid', relativeReference: false })
|
||||
await expect(e.parseAndRender('{% render "link" %}')).rejects.toThrow(/ENOENT|Failed to lookup/)
|
||||
})
|
||||
it('should not render a symlink partial via parseAndRenderSync', function () {
|
||||
const e = new Liquid({ root: [root], extname: '.liquid', relativeReference: false })
|
||||
expect(() => e.parseAndRenderSync('{% render "link" %}')).toThrow(/ENOENT|Failed to lookup/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +109,14 @@ describe('filters/string', function () {
|
||||
return test('{{ "Take my protein pills and put my helmet on" | replace: "my", "your" }}',
|
||||
'Take your protein pills and put your helmet on')
|
||||
})
|
||||
it('should support replace with undefined replacement', function () {
|
||||
return test('{{ "Take my protein pills and put my helmet on" | replace: "my" }}',
|
||||
'Take protein pills and put helmet on')
|
||||
})
|
||||
it('should support replace with undefined variable as replacement', function () {
|
||||
return test('{{ "Take my protein pills and put my helmet on" | replace: "my", missing_variable }}',
|
||||
'Take protein pills and put helmet on')
|
||||
})
|
||||
it('should support replace_first', function () {
|
||||
return test('{% assign my_string = "Take my protein pills and put my helmet on" %}\n' +
|
||||
'{{ my_string | replace_first: "my", "your" }}',
|
||||
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
import { isString, forOwn } from '../../src/util/underscore'
|
||||
import * as fs from '../../src/fs/fs-impl'
|
||||
import { resolve } from 'path'
|
||||
import { resolve, sep } from 'path'
|
||||
|
||||
interface FileDescriptor {
|
||||
mode: string;
|
||||
@@ -8,7 +8,7 @@ interface FileDescriptor {
|
||||
}
|
||||
|
||||
let files: { [path: string]: FileDescriptor } = {}
|
||||
const { readFile, exists, readFileSync, existsSync } = fs
|
||||
const { readFile, exists, readFileSync, existsSync, contains, containsSync } = fs
|
||||
|
||||
export function mock (options: { [path: string]: (string | FileDescriptor) }) {
|
||||
forOwn(options, (val, key) => {
|
||||
@@ -30,6 +30,16 @@ export function mock (options: { [path: string]: (string | FileDescriptor) }) {
|
||||
};
|
||||
(fs as any).existsSync = function (path: string) {
|
||||
return !!files[path]
|
||||
};
|
||||
(fs as any).contains = async (root: string, file: string) => {
|
||||
root = resolve(root)
|
||||
if (!root.endsWith(sep)) root += sep
|
||||
return file.startsWith(root)
|
||||
};
|
||||
(fs as any).containsSync = (root: string, file: string) => {
|
||||
root = resolve(root)
|
||||
if (!root.endsWith(sep)) root += sep
|
||||
return file.startsWith(root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,5 +48,7 @@ export function restore () {
|
||||
(fs as any).readFileSync = readFileSync;
|
||||
(fs as any).existsSync = existsSync;
|
||||
(fs as any).readFile = readFile;
|
||||
(fs as any).exists = exists
|
||||
(fs as any).exists = exists;
|
||||
(fs as any).contains = contains;
|
||||
(fs as any).containsSync = containsSync
|
||||
}
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"downlevelIteration": true,
|
||||
"strict": true,
|
||||
"suppressImplicitAnyIndexErrors": true
|
||||
"strict": true
|
||||
},
|
||||
"all": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user