Skip to content

internal: (studio) add manifest for all of the cloud delivered files #31923

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions packages/server/lib/cloud/api/studio/get_studio_bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import os from 'os'
import { agent } from '@packages/network'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { createWriteStream } from 'fs'
import { verifySignatureFromFile } from '../../encryption'

const pkg = require('@packages/root')
const _delay = linearDelay(500)

export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { studioUrl: string, projectId?: string, bundlePath: string }) => {
export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise<string> => {
let responseSignature: string | null = null

await (asyncRetry(async () => {
Expand Down Expand Up @@ -54,9 +53,5 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
throw new Error('Unable to get studio signature')
}

const verified = await verifySignatureFromFile(bundlePath, responseSignature)

if (!verified) {
throw new Error('Unable to verify studio signature')
}
return responseSignature
}
4 changes: 2 additions & 2 deletions packages/server/lib/cloud/encryption.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import crypto from 'crypto'
import crypto, { BinaryLike } from 'crypto'
import { TextEncoder, promisify } from 'util'
import { generalDecrypt, GeneralJWE } from 'jose'
import base64Url from 'base64url'
Expand Down Expand Up @@ -37,7 +37,7 @@ export interface EncryptRequestData {
secretKey: crypto.KeyObject
}

export function verifySignature (body: string, signature: string, publicKey?: crypto.KeyObject) {
export function verifySignature (body: BinaryLike, signature: string, publicKey?: crypto.KeyObject) {
const verify = crypto.createVerify('SHA256')

verify.update(body)
Expand Down
20 changes: 18 additions & 2 deletions packages/server/lib/cloud/studio/StudioLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme
import { telemetryManager } from './telemetry/TelemetryManager'
import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle'
import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization'
import crypto from 'crypto'

const debug = Debug('cypress:server:studio-lifecycle-manager')
const routes = require('../routes')

export class StudioLifecycleManager {
private static hashLoadingMap: Map<string, Promise<void>> = new Map()
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
private static watcher: chokidar.FSWatcher | null = null
private studioManagerPromise?: Promise<StudioManager | null>
private studioManager?: StudioManager
Expand Down Expand Up @@ -157,6 +158,7 @@ export class StudioLifecycleManager {
}): Promise<StudioManager> {
let studioPath: string
let studioHash: string
let manifest: Record<string, string>

initializeTelemetryReporter({
projectSlug: projectId,
Expand Down Expand Up @@ -190,17 +192,30 @@ export class StudioLifecycleManager {
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
}

await hashLoadingPromise
manifest = await hashLoadingPromise
} else {
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
studioHash = 'local'
manifest = {}
}

telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_END)

const serverFilePath = path.join(studioPath, 'server', 'index.js')

const script = await readFile(serverFilePath, 'utf8')

const expectedHash = manifest[path.join('server', 'index.js')]

// TODO: once the services have deployed, we should remove this check
if (expectedHash) {
const actualHash = crypto.createHash('sha256').update(script).digest('hex')

if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && actualHash !== expectedHash) {
throw new Error('Invalid hash for studio server script')
}
}

const studioManager = new StudioManager()

telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_START)
Expand All @@ -220,6 +235,7 @@ export class StudioLifecycleManager {
asyncRetry,
},
shouldEnableStudio: this.cloudStudioRequested,
manifest,
})

telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)
Expand Down
21 changes: 16 additions & 5 deletions packages/server/lib/cloud/studio/ensure_studio_bundle.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { remove, ensureDir } from 'fs-extra'
import { remove, ensureDir, readFile } from 'fs-extra'

import tar from 'tar'
import { getStudioBundle } from '../api/studio/get_studio_bundle'
import path from 'path'
import { verifySignature } from '../encryption'

interface EnsureStudioBundleOptions {
studioUrl: string
Expand All @@ -26,7 +27,7 @@ export const ensureStudioBundle = async ({
projectId,
studioPath,
downloadTimeoutMs = DOWNLOAD_TIMEOUT,
}: EnsureStudioBundleOptions) => {
}: EnsureStudioBundleOptions): Promise<Record<string, string>> => {
const bundlePath = path.join(studioPath, 'bundle.tar')

// First remove studioPath to ensure we have a clean slate
Expand All @@ -35,10 +36,9 @@ export const ensureStudioBundle = async ({

let timeoutId: NodeJS.Timeout

await Promise.race([
const responseSignature = await Promise.race([
getStudioBundle({
studioUrl,
projectId,
bundlePath,
}),
new Promise((_, reject) => {
Expand All @@ -48,10 +48,21 @@ export const ensureStudioBundle = async ({
}),
]).finally(() => {
clearTimeout(timeoutId)
})
}) as string

await tar.extract({
file: bundlePath,
cwd: studioPath,
})

const manifestPath = path.join(studioPath, 'manifest.json')
const manifestContents = await readFile(manifestPath, 'utf8')

const verified = await verifySignature(manifestContents, responseSignature)

if (!verified) {
throw new Error('Unable to verify studio signature')
}

return JSON.parse(manifestContents)
}
16 changes: 15 additions & 1 deletion packages/server/lib/cloud/studio/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Debug from 'debug'
import { requireScript } from '../require_script'
import path from 'path'
import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error'
import crypto, { BinaryLike } from 'crypto'

interface StudioServer { default: StudioServerDefaultShape }

Expand All @@ -15,6 +16,7 @@ interface SetupOptions {
projectSlug?: string
cloudApi: StudioCloudApi
shouldEnableStudio: boolean
manifest: Record<string, string>
}

const debug = Debug('cypress:server:studio')
Expand All @@ -41,7 +43,7 @@ export class StudioManager implements StudioManagerShape {
return manager
}

async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise<void> {
async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, manifest }: SetupOptions): Promise<void> {
const { createStudioServer } = requireScript<StudioServer>(script).default

this._studioServer = await createStudioServer({
Expand All @@ -50,6 +52,18 @@ export class StudioManager implements StudioManagerShape {
projectSlug,
cloudApi,
betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')),
manifest,
verifyHash: (contents: BinaryLike, expectedHash: string) => {
// If we are running locally, we don't need to verify the signature. This
// environment variable will get stripped in the binary.
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
return true
}

const actualHash = crypto.createHash('sha256').update(contents).digest('hex')

return actualHash === expectedHash
},
})

this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ describe('getStudioBundle', () => {
let readStream: Readable
let createWriteStreamStub: sinon.SinonStub
let crossFetchStub: sinon.SinonStub
let verifySignatureFromFileStub: sinon.SinonStub
let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle

beforeEach(() => {
createWriteStreamStub = sinon.stub()
crossFetchStub = sinon.stub()
verifySignatureFromFileStub = sinon.stub()
readStream = Readable.from('console.log("studio bundle")')

writeResult = ''
Expand All @@ -31,9 +29,6 @@ describe('getStudioBundle', () => {
createWriteStream: createWriteStreamStub,
},
'cross-fetch': crossFetchStub,
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
'os': {
platform: () => 'linux',
},
Expand All @@ -57,11 +52,7 @@ describe('getStudioBundle', () => {
},
})

verifySignatureFromFileStub.resolves(true)

const projectId = '12345'

await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match.any,
Expand All @@ -77,7 +68,7 @@ describe('getStudioBundle', () => {

expect(writeResult).to.eq('console.log("studio bundle")')

expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
expect(responseSignature).to.eq('159')
})

it('downloads the studio bundle and extracts it after 1 fetch failure', async () => {
Expand All @@ -95,11 +86,7 @@ describe('getStudioBundle', () => {
},
})

verifySignatureFromFileStub.resolves(true)

const projectId = '12345'

await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match.any,
Expand All @@ -115,17 +102,15 @@ describe('getStudioBundle', () => {

expect(writeResult).to.eq('console.log("studio bundle")')

expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
expect(responseSignature).to.eq('159')
})

it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => {
const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())

crossFetchStub.rejects(error)

const projectId = '12345'

await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected

expect(crossFetchStub).to.be.calledThrice
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
Expand All @@ -147,9 +132,7 @@ describe('getStudioBundle', () => {
statusText: 'Some failure',
})

const projectId = '12345'

await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match.any,
Expand All @@ -164,45 +147,6 @@ describe('getStudioBundle', () => {
})
})

it('throws an error and returns a studio manager in error state if the signature verification fails', async () => {
verifySignatureFromFileStub.resolves(false)

crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
},
},
})

verifySignatureFromFileStub.resolves(false)

const projectId = '12345'

await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected

expect(writeResult).to.eq('console.log("studio bundle")')

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match.any,
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})

expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
})

it('throws an error if there is no signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
Expand All @@ -213,9 +157,7 @@ describe('getStudioBundle', () => {
},
})

const projectId = '12345'

await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match.any,
Expand Down
Loading
Loading