image-gzip.spec.ts 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. /**
  2. * The image byte envelope: the worker inflates one gzip member off the response
  3. * stream and refuses anything else by name.
  4. *
  5. * The refusal is the load-bearing case. A deployment that serves a plain tar, a
  6. * truncated download, or a proxy's HTML error page under the image URL would
  7. * otherwise reach the tar reader, which reports a corrupt header field and says
  8. * nothing about what arrived — so the check has to name the source and the format
  9. * it expected. It also has to survive chunking: the two identification bytes may
  10. * arrive one at a time, which the single-byte case below feeds deliberately.
  11. */
  12. import { gzipSync } from 'node:zlib'
  13. import { describe, expect, it } from 'vitest'
  14. import { inflateImage, inflateImageStream } from '../../src/storage/image-gzip.ts'
  15. import { loadVfsImage } from '../../src/storage/memory.ts'
  16. import { packTar } from '../../src/storage/tar.ts'
  17. const encoder = new TextEncoder()
  18. const decoder = new TextDecoder()
  19. /** A two-entry archive, as the packer would lay one out under the virtual root. */
  20. const archive = (): Uint8Array => packTar({
  21. 'config/cordis.yml': encoder.encode('- id: subject\n'),
  22. 'node_modules/@scope/pkg/lib/index.js': encoder.encode('exports.answer = 42\n'),
  23. })
  24. /** The bytes as a body delivered `size` bytes at a time, the way a transport may. */
  25. const chunked = (bytes: Uint8Array, size: number): ReadableStream<Uint8Array> => {
  26. let at = 0
  27. return new ReadableStream<Uint8Array>({
  28. pull: (controller): void => {
  29. if (at >= bytes.byteLength) {
  30. controller.close()
  31. return
  32. }
  33. controller.enqueue(bytes.slice(at, at + size))
  34. at += size
  35. },
  36. })
  37. }
  38. describe('image gzip envelope', () => {
  39. it('inflates a compressed image into the archive the packer wrote', async () => {
  40. const tar = archive()
  41. const inflated = await inflateImage(gzipSync(tar), 'the spec image')
  42. expect(inflated.byteLength).toBe(tar.byteLength)
  43. expect([...inflated]).toEqual([...tar])
  44. })
  45. it('inflates a body delivered one byte at a time', async () => {
  46. const tar = archive()
  47. const inflated = await inflateImageStream(chunked(gzipSync(tar), 1), 'the spec image')
  48. expect([...inflated]).toEqual([...tar])
  49. })
  50. it('mounts an inflated image through the real VFS loader', async () => {
  51. const vfs = loadVfsImage(await inflateImage(gzipSync(archive()), 'the spec image'), '/dsh')
  52. expect(vfs.existsSync('/dsh/node_modules/@scope/pkg/lib/index.js')).toBe(true)
  53. expect(decoder.decode(vfs.readFileSync('/dsh/config/cordis.yml') as Uint8Array)).toBe('- id: subject\n')
  54. })
  55. it('refuses an uncompressed tar, naming the source and the expected member', async () => {
  56. await expect(inflateImage(archive(), 'https://example.test/vfs-image.tar.gz')).rejects.toThrow(
  57. /https:\/\/example\.test\/vfs-image\.tar\.gz is not the gzip-compressed tar .*expected a member starting 1f 8b, read/,
  58. )
  59. })
  60. it('refuses a page served in the image\'s place, quoting what it read', async () => {
  61. const page = encoder.encode('<!doctype html><title>404</title>')
  62. await expect(inflateImage(page, 'the image bytes')).rejects.toThrow(/read 3c 21 64 6f 63 74 79 70/)
  63. })
  64. it('refuses a body that ends before the header, one byte at a time', async () => {
  65. await expect(inflateImageStream(chunked(new Uint8Array([0x1f]), 1), 'the spec image'))
  66. .rejects.toThrow(/expected a member starting 1f 8b, read 1f/)
  67. })
  68. it('refuses an empty body as such', async () => {
  69. await expect(inflateImage(new Uint8Array(0), 'the spec image')).rejects.toThrow(/read an empty body/)
  70. })
  71. it('refuses a truncated gzip member instead of mounting a partial archive', async () => {
  72. const compressed = gzipSync(archive())
  73. await expect(inflateImage(compressed.subarray(0, compressed.byteLength - 64), 'the spec image')).rejects.toThrow()
  74. })
  75. })