Decrypt.swift 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /*
  2. * Copyright 2025 Google LLC
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. import ArgumentParser
  17. import Foundation
  18. import Logging
  19. import Util
  20. extension Tests {
  21. /// Command for decrypting the secret files needed for a test run.
  22. struct Decrypt: ParsableCommand {
  23. nonisolated(unsafe) static var configuration = CommandConfiguration(
  24. abstract: "Decrypt the secret files for a test run.",
  25. usage: """
  26. tests decrypt [--json] [--overwrite] [<json-file>]
  27. tests decrypt [--password <password>] [--overwrite] [<secret-files> ...]
  28. tests decrypt --json secret_files.json
  29. tests decrypt --json --overwrite secret_files.json
  30. tests decrypt --password "super_secret" \\
  31. scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\
  32. scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist
  33. """,
  34. discussion: """
  35. The happy path usage is saving the secret passphrase in the environment variable \
  36. 'secrets_passphrase', and passing a json file to the command. Although, you can also \
  37. pass everything inline via options.
  38. When using a json file, it's expected that the json file is an array of json elements \
  39. in the format of:
  40. { encrypted: <path-to-encrypted-file>, destination: <where-to-output-decrypted-file> }
  41. """,
  42. )
  43. @Argument(
  44. help: """
  45. An array of secret files to decrypt. \
  46. The files should be in the format "encrypted:destination", where "encrypted" is a path to \
  47. the encrypted file and "destination" is a path to where the decrypted file should be saved.
  48. """
  49. )
  50. var secretFiles: [String] = []
  51. @Option(
  52. help: """
  53. The secret to use when decrypting the files. \
  54. Defaults to the environment variable 'secrets_passphrase'.
  55. """
  56. )
  57. var password: String = ""
  58. @Flag(help: "Overwrite existing decrypted secret files.")
  59. var overwrite: Bool = false
  60. @Flag(
  61. help: """
  62. Use a json file of secret file mappings instead. \
  63. When this flag is enabled, <secret-files> should be a single json file.
  64. """
  65. )
  66. var json: Bool = false
  67. /// The parsed version of ``secretFiles``.
  68. ///
  69. /// Only populated after `validate()` runs.
  70. var files: [SecretFile] = []
  71. static let log = Logger(label: "Tests::Decrypt")
  72. private var log: Logger { Decrypt.log }
  73. mutating func validate() throws {
  74. try validatePassword()
  75. if json {
  76. try validateJSON()
  77. } else {
  78. try validateFileString()
  79. }
  80. if !overwrite {
  81. log.info("Overwrite is disabled, so we're skipping generation for existing files.")
  82. files = files.filter { file in
  83. let keep = !FileManager.default.fileExists(atPath: file.destination)
  84. if !keep {
  85. log.debug(
  86. "Skipping generation for existing file",
  87. metadata: ["destination": "\(file.destination)"]
  88. )
  89. }
  90. return keep
  91. }
  92. }
  93. for file in files {
  94. guard FileManager.default.fileExists(atPath: file.encrypted) else {
  95. throw ValidationError("Encrypted secret file does not exist: \(file.encrypted)")
  96. }
  97. }
  98. }
  99. private mutating func validatePassword() throws {
  100. if password.isEmpty {
  101. // when a password isn't provided, try to load one from the environment variable
  102. guard
  103. let secrets_passphrase = ProcessInfo.processInfo.environment["secrets_passphrase"]
  104. else {
  105. throw ValidationError(
  106. "Either provide a passphrase via the password option or set the environvment variable 'secrets_passphrase' to the passphrase."
  107. )
  108. }
  109. password = secrets_passphrase
  110. }
  111. }
  112. private mutating func validateJSON() throws {
  113. guard let jsonPath = secretFiles.first else {
  114. throw ValidationError("Missing path to json file for secret files")
  115. }
  116. let fileURL = URL(
  117. filePath: jsonPath, directoryHint: .notDirectory,
  118. relativeTo: URL.currentDirectory()
  119. )
  120. files = try SecretFile.parseArrayFrom(file: fileURL)
  121. guard !files.isEmpty else {
  122. throw ValidationError("Missing secret files in json file: \(jsonPath)")
  123. }
  124. }
  125. private mutating func validateFileString() throws {
  126. guard !secretFiles.isEmpty else {
  127. throw ValidationError("Missing paths to secret files")
  128. }
  129. for string in secretFiles {
  130. try files.append(SecretFile(string: string))
  131. }
  132. }
  133. mutating func run() throws {
  134. log.info("Decrypting files...")
  135. for file in files {
  136. let gpg = Process("gpg", inheritEnvironment: true)
  137. let result = try gpg.runWithSignals([
  138. "--quiet",
  139. "--batch",
  140. "--yes",
  141. "--decrypt",
  142. "--passphrase=\(password)",
  143. "--output",
  144. file.destination,
  145. file.encrypted,
  146. ])
  147. guard result == 0 else {
  148. log.error("Failed to decrypt file", metadata: ["file": "\(file.encrypted)"])
  149. throw ExitCode(result)
  150. }
  151. log.debug(
  152. "File encrypted",
  153. metadata: ["file": "\(file.encrypted)", "destination": "\(file.destination)"]
  154. )
  155. }
  156. log.info("Files decrypted")
  157. }
  158. }
  159. }