diff --git a/packages/cso-cli/src/assets.ts b/packages/cso-cli/src/assets.ts index 75a53ca..c8d729f 100644 --- a/packages/cso-cli/src/assets.ts +++ b/packages/cso-cli/src/assets.ts @@ -1,5 +1,5 @@ import { readFileSync, realpathSync, statSync } from 'node:fs'; -import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { createHash } from 'node:crypto'; import { ResolvedAssetSchema, @@ -23,7 +23,11 @@ export function captureAssets( resolve(root, dirname(asset.moduleId), asset.path), ); const within = relative(root, path); - if (within === '..' || within.startsWith('../') || isAbsolute(within)) + if ( + within === '..' || + within.startsWith(`..${sep}`) || + isAbsolute(within) + ) throw new Error('Asset resolves outside the entry source directory'); if (!statSync(path).isFile()) throw new Error('Asset is not a regular file'); diff --git a/packages/cso-cli/tests/assets.test.ts b/packages/cso-cli/tests/assets.test.ts new file mode 100644 index 0000000..b351c71 --- /dev/null +++ b/packages/cso-cli/tests/assets.test.ts @@ -0,0 +1,142 @@ +import { createHash } from 'node:crypto'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + ExecutionPayloadSchema, + namespacedId, + sourceClosureHash, +} from '@cs-object/core'; +import { afterEach, expect, test } from 'vitest'; +import { captureAssets } from '../src/assets.ts'; + +const svg = ''; +const dataUrl = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); + +function fixture(assetPath = 'diagram.svg') { + const directory = mkdtempSync(join(tmpdir(), 'cso-assets-')); + roots.push(directory); + const root = join(directory, 'calculation'); + const sourcePath = join(root, 'report.cso.py'); + const imagePath = join(root, assetPath); + mkdirSync(dirname(imagePath), { recursive: true }); + writeFileSync(sourcePath, 'pass\n'); + writeFileSync(imagePath, svg); + const hash = (path: string) => + createHash('sha256').update(readFileSync(path)).digest('hex'); + const moduleId = 'report.cso.py'; + const sectionId = namespacedId('section', 'root', 'main'); + const sourceManifest = [{ moduleId, sha256: hash(sourcePath) }]; + const execution = ExecutionPayloadSchema.parse({ + cso: { + schemaVersion: '1.0.0', + title: 'Asset capture', + source: { id: 'asset-capture', metadata: {} }, + rootSectionIds: [sectionId], + sections: [{ id: sectionId, title: 'Assets', items: [] }], + }, + entry: { + moduleId, + function: 'calculate', + sourceHash: hash(sourcePath), + invocationId: 'root', + resolvedInputs: {}, + }, + sourceManifest, + sourceClosureHash: sourceClosureHash(sourceManifest), + invocations: [ + { + id: 'root', + moduleId, + function: 'calculate', + resolvedInputs: {}, + inputBindings: [], + symbols: [], + }, + ], + observations: [], + assets: [ + { + id: 'diagram', + moduleId, + path: assetPath, + mediaType: 'image/svg+xml', + sha256: hash(imagePath), + }, + ], + versions: { + pythonPackage: '0.1.0', + pythonInterpreter: 'python', + pythonVersion: '3.11.0', + }, + }); + return { directory, root, sourcePath, imagePath, execution }; +} + +test.each(['..notes/diagram.svg', '..diagram.svg'])( + 'captures a contained asset named %s', + (path) => { + const { sourcePath, execution } = fixture(path); + expect(captureAssets(sourcePath, execution)).toEqual([ + { asset: execution.assets[0], dataUrl }, + ]); + }, +); + +test('rejects a directory link retargeted to a sibling after its asset hash was captured', () => { + const { directory, root, sourcePath, imagePath, execution } = + fixture('linked/diagram.svg'); + const inside = join(root, 'images'); + const link = dirname(imagePath); + const outside = join(directory, 'calculation-sibling'); + renameSync(link, inside); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + symlinkSync(inside, link, linkType); + expect(captureAssets(sourcePath, execution)).toEqual([ + { asset: execution.assets[0], dataUrl }, + ]); + + mkdirSync(outside); + writeFileSync(join(outside, 'diagram.svg'), svg); + unlinkSync(link); + symlinkSync(outside, link, linkType); + expect(() => captureAssets(sourcePath, execution)).toThrow( + 'Asset resolves outside the entry source directory', + ); +}); + +test('retains copied asset bytes and rejects changes after the recorded hash', () => { + const { sourcePath, imagePath, execution } = fixture(); + const captured = captureAssets(sourcePath, execution); + writeFileSync( + imagePath, + '', + ); + expect(captured).toEqual([{ asset: execution.assets[0], dataUrl }]); + expect(() => captureAssets(sourcePath, execution)).toThrow( + 'Asset bytes changed since Python capture', + ); +}); + +test('rejects a matching hash with an incorrect declared media signature', () => { + const { sourcePath, execution } = fixture(); + execution.assets[0].mediaType = 'image/png'; + expect(() => captureAssets(sourcePath, execution)).toThrow( + 'Declared PNG has the wrong media signature', + ); +});