1const { fixer } = require('normalize-package-data')
2const npmFetch = require('npm-registry-fetch')
3const npa = require('npm-package-arg')
4const log = require('proc-log')
5const semver = require('semver')
6const { URL } = require('url')
7const ssri = require('ssri')
8const ciInfo = require('ci-info')
9
10const { generateProvenance, verifyProvenance } = require('./provenance')
11
12const TLOG_BASE_URL = 'https://search.sigstore.dev/'
13
14const publish = async (manifest, tarballData, opts) => {
15  if (manifest.private) {
16    throw Object.assign(
17      new Error(`This package has been marked as private
18Remove the 'private' field from the package.json to publish it.`),
19      { code: 'EPRIVATE' }
20    )
21  }
22
23  // spec is used to pick the appropriate registry/auth combo
24  const spec = npa.resolve(manifest.name, manifest.version)
25  opts = {
26    access: 'public',
27    algorithms: ['sha512'],
28    defaultTag: 'latest',
29    ...opts,
30    spec,
31  }
32
33  const reg = npmFetch.pickRegistry(spec, opts)
34  const pubManifest = patchManifest(manifest, opts)
35
36  // registry-frontdoor cares about the access level,
37  // which is only configurable for scoped packages
38  if (!spec.scope && opts.access === 'restricted') {
39    throw Object.assign(
40      new Error("Can't restrict access to unscoped packages."),
41      { code: 'EUNSCOPED' }
42    )
43  }
44
45  const { metadata, transparencyLogUrl } = await buildMetadata(
46    reg,
47    pubManifest,
48    tarballData,
49    spec,
50    opts
51  )
52
53  const res = await npmFetch(spec.escapedName, {
54    ...opts,
55    method: 'PUT',
56    body: metadata,
57    ignoreBody: true,
58  })
59  if (transparencyLogUrl) {
60    res.transparencyLogUrl = transparencyLogUrl
61  }
62  return res
63}
64
65const patchManifest = (_manifest, opts) => {
66  const { npmVersion } = opts
67  // we only update top-level fields, so a shallow clone is fine
68  const manifest = { ..._manifest }
69
70  manifest._nodeVersion = process.versions.node
71  if (npmVersion) {
72    manifest._npmVersion = npmVersion
73  }
74
75  fixer.fixNameField(manifest, { strict: true, allowLegacyCase: true })
76  const version = semver.clean(manifest.version)
77  if (!version) {
78    throw Object.assign(
79      new Error('invalid semver: ' + manifest.version),
80      { code: 'EBADSEMVER' }
81    )
82  }
83  manifest.version = version
84  return manifest
85}
86
87const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
88  const { access, defaultTag, algorithms, provenance, provenanceFile } = opts
89  const root = {
90    _id: manifest.name,
91    name: manifest.name,
92    description: manifest.description,
93    'dist-tags': {},
94    versions: {},
95    access,
96  }
97
98  root.versions[manifest.version] = manifest
99  const tag = manifest.tag || defaultTag
100  root['dist-tags'][tag] = manifest.version
101
102  const tarballName = `${manifest.name}-${manifest.version}.tgz`
103  const provenanceBundleName = `${manifest.name}-${manifest.version}.sigstore`
104  const tarballURI = `${manifest.name}/-/${tarballName}`
105  const integrity = ssri.fromData(tarballData, {
106    algorithms: [...new Set(['sha1'].concat(algorithms))],
107  })
108
109  manifest._id = `${manifest.name}@${manifest.version}`
110  manifest.dist = { ...manifest.dist }
111  // Don't bother having sha1 in the actual integrity field
112  manifest.dist.integrity = integrity.sha512[0].toString()
113  // Legacy shasum support
114  manifest.dist.shasum = integrity.sha1[0].hexDigest()
115
116  // NB: the CLI always fetches via HTTPS if the registry is HTTPS,
117  // regardless of what's here.  This makes it so that installing
118  // from an HTTP-only mirror doesn't cause problems, though.
119  manifest.dist.tarball = new URL(tarballURI, registry).href
120    .replace(/^https:\/\//, 'http://')
121
122  root._attachments = {}
123  root._attachments[tarballName] = {
124    content_type: 'application/octet-stream',
125    data: tarballData.toString('base64'),
126    length: tarballData.length,
127  }
128
129  // Handle case where --provenance flag was set to true
130  let transparencyLogUrl
131  if (provenance === true || provenanceFile) {
132    let provenanceBundle
133    const subject = {
134      name: npa.toPurl(spec),
135      digest: { sha512: integrity.sha512[0].hexDigest() },
136    }
137
138    if (provenance === true) {
139      await ensureProvenanceGeneration(registry, spec, opts)
140      provenanceBundle = await generateProvenance([subject], opts)
141
142      /* eslint-disable-next-line max-len */
143      log.notice('publish', `Signed provenance statement with source and build information from ${ciInfo.name}`)
144
145      const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
146      /* istanbul ignore else */
147      if (tlogEntry) {
148        transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
149        log.notice(
150          'publish',
151          `Provenance statement published to transparency log: ${transparencyLogUrl}`
152        )
153      }
154    } else {
155      provenanceBundle = await verifyProvenance(subject, provenanceFile)
156    }
157
158    const serializedBundle = JSON.stringify(provenanceBundle)
159    root._attachments[provenanceBundleName] = {
160      content_type: provenanceBundle.mediaType,
161      data: serializedBundle,
162      length: serializedBundle.length,
163    }
164  }
165
166  return {
167    metadata: root,
168    transparencyLogUrl,
169  }
170}
171
172// Check that all the prereqs are met for provenance generation
173const ensureProvenanceGeneration = async (registry, spec, opts) => {
174  if (ciInfo.GITHUB_ACTIONS) {
175    // Ensure that the GHA OIDC token is available
176    if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
177      throw Object.assign(
178        /* eslint-disable-next-line max-len */
179        new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
180        { code: 'EUSAGE' }
181      )
182    }
183  } else if (ciInfo.GITLAB) {
184    // Ensure that the Sigstore OIDC token is available
185    if (!process.env.SIGSTORE_ID_TOKEN) {
186      throw Object.assign(
187        /* eslint-disable-next-line max-len */
188        new Error('Provenance generation in GitLab CI requires "SIGSTORE_ID_TOKEN" with "sigstore" audience to be present in "id_tokens". For more info see:\nhttps://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html'),
189        { code: 'EUSAGE' }
190      )
191    }
192  } else {
193    throw Object.assign(
194      new Error('Automatic provenance generation not supported for provider: ' + ciInfo.name),
195      { code: 'EUSAGE' }
196    )
197  }
198
199  // Some registries (e.g. GH packages) require auth to check visibility,
200  // and always return 404 when no auth is supplied. In this case we assume
201  // the package is always private and require `--access public` to publish
202  // with provenance.
203  let visibility = { public: false }
204  if (opts.access !== 'public') {
205    try {
206      const res = await npmFetch
207        .json(`${registry}/-/package/${spec.escapedName}/visibility`, opts)
208      visibility = res
209    } catch (err) {
210      if (err.code !== 'E404') {
211        throw err
212      }
213    }
214  }
215
216  if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
217    throw Object.assign(
218      /* eslint-disable-next-line max-len */
219      new Error("Can't generate provenance for new or private package, you must set `access` to public."),
220      { code: 'EUSAGE' }
221    )
222  }
223}
224
225module.exports = publish
226