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 9 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
10 changes: 9 additions & 1 deletion packages/server/lib/cloud/api/studio/get_studio_bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ 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
let responseManifestSignature: string | null = null

await (asyncRetry(async () => {
const response = await fetch(studioUrl, {
Expand All @@ -32,6 +33,7 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
}

responseSignature = response.headers.get('x-cypress-signature')
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')

await new Promise<void>((resolve, reject) => {
const writeStream = createWriteStream(bundlePath)
Expand All @@ -54,9 +56,15 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
throw new Error('Unable to get studio signature')
}

if (!responseManifestSignature) {
throw new Error('Unable to get studio manifest signature')
}

const verified = await verifySignatureFromFile(bundlePath, responseSignature)

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

return responseManifestSignature
}
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
26 changes: 21 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, pathExists } 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 responseManifestSignature = await Promise.race([
getStudioBundle({
studioUrl,
projectId,
bundlePath,
}),
new Promise((_, reject) => {
Expand All @@ -48,10 +48,26 @@ export const ensureStudioBundle = async ({
}),
]).finally(() => {
clearTimeout(timeoutId)
})
}) as string

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

const manifestPath = path.join(studioPath, 'manifest.json')

if (!(await pathExists(manifestPath))) {
throw new Error('Unable to find studio manifest')
}

const manifestContents = await readFile(manifestPath, 'utf8')

const verified = await verifySignature(manifestContents, responseManifestSignature)

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 @@ -31,15 +31,15 @@ describe('getStudioBundle', () => {
createWriteStream: createWriteStreamStub,
},
'cross-fetch': crossFetchStub,
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
'os': {
platform: () => 'linux',
},
'@packages/root': {
version: '1.2.3',
},
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
}).getStudioBundle
})

Expand All @@ -53,15 +53,17 @@ describe('getStudioBundle', () => {
if (header === 'x-cypress-signature') {
return '159'
}

if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})

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 @@ -78,6 +80,8 @@ 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('160')
})

it('downloads the studio bundle and extracts it after 1 fetch failure', async () => {
Expand All @@ -91,15 +95,17 @@ describe('getStudioBundle', () => {
if (header === 'x-cypress-signature') {
return '159'
}

if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})

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 @@ -116,16 +122,16 @@ 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('160')
})

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 +153,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,7 +168,7 @@ describe('getStudioBundle', () => {
})
})

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

crossFetchStub.resolves({
Expand All @@ -176,15 +180,17 @@ describe('getStudioBundle', () => {
if (header === 'x-cypress-signature') {
return '159'
}

if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})

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
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected

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

Expand All @@ -209,13 +215,44 @@ describe('getStudioBundle', () => {
statusText: 'OK',
body: readStream,
headers: {
get: () => null,
get: (header) => {
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})

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

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',
})
})

const projectId = '12345'
it('throws an error if there is no manifest signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
},
},
})

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.rejectedWith('Unable to get studio manifest signature')

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