1 /*
<lambda>null2  * * Copyright 2022 Google LLC. All rights reserved.
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 
17 package com.google.devtools.kotlin.srczip
18 
19 import java.io.BufferedInputStream
20 import java.io.BufferedOutputStream
21 import java.nio.file.Files
22 import java.nio.file.Path
23 import java.nio.file.Paths
24 import java.nio.file.StandardCopyOption
25 import java.time.LocalDateTime
26 import java.util.zip.ZipEntry
27 import java.util.zip.ZipInputStream
28 import java.util.zip.ZipOutputStream
29 import kotlin.system.exitProcess
30 import picocli.CommandLine
31 import picocli.CommandLine.Command
32 import picocli.CommandLine.Model.CommandSpec
33 import picocli.CommandLine.Option
34 import picocli.CommandLine.ParameterException
35 import picocli.CommandLine.Parameters
36 import picocli.CommandLine.Spec
37 
38 @Command(
39   name = "source-jar-zipper",
40   subcommands = [Unzip::class, Zip::class, ZipResources::class],
41   description = ["A tool to pack and unpack srcjar files, and to zip resource files"],
42 )
43 class SourceJarZipper : Runnable {
44   @Spec private lateinit var spec: CommandSpec
45 
46   override fun run() {
47     throw ParameterException(spec.commandLine(), "Specify a command: zip, zip_resources or unzip")
48   }
49 }
50 
mainnull51 fun main(args: Array<String>) {
52   val exitCode = CommandLine(SourceJarZipper()).execute(*args)
53   exitProcess(exitCode)
54 }
55 
56 /**
57  * Checks for duplicates and adds an entry into [errors] if one is found, otherwise adds a pair of
58  * [zipPath] and [sourcePath] to the receiver
59  *
60  * @param[zipPath] relative path inside the jar, built either from package name (e.g. package
61  *   com.google.foo -> com/google/foo/FileName.kt) or by resolving the file name relative to the
62  *   directory it came from (e.g. foo/bar/1/2.txt came from foo/bar -> 1/2.txt)
63  * @param[sourcePath] full path of file into its file system
64  * @param[errors] list of strings describing catched errors
65  * @receiver a mutable map of path to path, where keys are relative paths of files inside the
66  *   resulting .jar, and values are full paths of files
67  */
checkForDuplicatesAndSetFilePathToPathInsideJarnull68 fun MutableMap<Path, Path>.checkForDuplicatesAndSetFilePathToPathInsideJar(
69   zipPath: Path,
70   sourcePath: Path,
71   errors: MutableList<String>,
72 ) {
73   val duplicatedSourcePath: Path? = this[zipPath]
74   if (duplicatedSourcePath == null) {
75     this[zipPath] = sourcePath
76   } else {
77     errors.add(
78       "${sourcePath} has the same path inside .jar as ${duplicatedSourcePath}! " +
79         "If it is intended behavior rename one or both of them."
80     )
81   }
82 }
83 
clearSingletonEmptyPathnull84 private fun clearSingletonEmptyPath(list: MutableList<Path>) {
85   if (list.size == 1 && list[0].toString() == "") {
86     list.clear()
87   }
88 }
89 
90 // Normalize timestamps
91 val DEFAULT_TIMESTAMP = LocalDateTime.of(2010, 1, 1, 0, 0, 0)
92 
writeToStreamnull93 fun MutableMap<Path, Path>.writeToStream(
94   zipper: ZipOutputStream,
95   prefix: String = "",
96 ) {
97   for ((zipPath, sourcePath) in this) {
98     BufferedInputStream(Files.newInputStream(sourcePath)).use { inputStream ->
99       val entry = ZipEntry(Paths.get(prefix).resolve(zipPath).toString())
100       entry.timeLocal = DEFAULT_TIMESTAMP
101       zipper.putNextEntry(entry)
102       inputStream.copyTo(zipper, bufferSize = 1024)
103     }
104   }
105 }
106 
107 @Command(name = "zip", description = ["Zip source files into a source jar file"])
108 class Zip : Runnable {
109 
110   @Parameters(index = "0", paramLabel = "outputJar", description = ["Output jar"])
111   lateinit var outputJar: Path
112 
113   @Option(
114     names = ["-i", "--ignore_not_allowed_files"],
115     description = ["Ignore not .kt, .java or invalid file paths without raising an exception"],
116   )
117   var ignoreNotAllowedFiles = false
118 
119   @Option(
120     names = ["--kotlin_srcs"],
121     split = ",",
122     description = ["Kotlin source files"],
123   )
124   val kotlinSrcs = mutableListOf<Path>()
125 
126   @Option(
127     names = ["--common_srcs"],
128     split = ",",
129     description = ["Common source files"],
130   )
131   val commonSrcs = mutableListOf<Path>()
132 
133   private companion object {
134     const val PACKAGE_SPACE = "package "
135     // can't start with digit and can't be all underscores
136     val IDENTIFIER_REGEX = Regex("(?:[a-zA-Z]|_+[a-zA-Z0-9])\\w*")
137     val PACKAGE_NAME_REGEX = Regex("$IDENTIFIER_REGEX(?:\\.$IDENTIFIER_REGEX)*")
138   }
139 
runnull140   override fun run() {
141     clearSingletonEmptyPath(kotlinSrcs)
142     clearSingletonEmptyPath(commonSrcs)
143 
144     // Validating files and getting paths for resulting .jar in one cycle
145     // for each _srcs list
146     val ktZipPathToSourcePath = mutableMapOf<Path, Path>()
147     val commonZipPathToSourcePath = mutableMapOf<Path, Path>()
148     val errors = mutableListOf<String>()
149 
150     fun Path.getPackagePath(): Path {
151       this.toFile().bufferedReader().use { stream ->
152         while (true) {
153           val line = stream.readLine() ?: return this.fileName
154 
155           if (line.startsWith(PACKAGE_SPACE)) {
156             // Kotlin allows usage of reserved words in package names framing them
157             // with backquote symbol "`"
158             val packageName =
159               line
160                 .removePrefix(PACKAGE_SPACE)
161                 .substringBefore("//")
162                 .trim()
163                 .removeSuffix(";")
164                 .replace(Regex("\\B`(.+?)`\\B"), "$1")
165             if (!PACKAGE_NAME_REGEX.matches(packageName)) {
166               errors.add("$this contains an invalid package name")
167               return this.fileName
168             }
169             return Paths.get(packageName.replace(".", "/")).resolve(this.fileName)
170           }
171         }
172       }
173     }
174 
175     fun Path.validateFile(): Boolean {
176       when {
177         !Files.isRegularFile(this) -> {
178           if (!ignoreNotAllowedFiles) errors.add("${this} is not a file")
179           return false
180         }
181         !this.toString().endsWith(".kt") && !this.toString().endsWith(".java") -> {
182           if (!ignoreNotAllowedFiles) errors.add("${this} is not a Kotlin file")
183           return false
184         }
185         else -> return true
186       }
187     }
188 
189     for (sourcePath in kotlinSrcs) {
190       if (sourcePath.validateFile()) {
191         ktZipPathToSourcePath.checkForDuplicatesAndSetFilePathToPathInsideJar(
192           sourcePath.getPackagePath(),
193           sourcePath,
194           errors,
195         )
196       }
197     }
198 
199     for (sourcePath in commonSrcs) {
200       if (sourcePath.validateFile()) {
201         commonZipPathToSourcePath.checkForDuplicatesAndSetFilePathToPathInsideJar(
202           sourcePath.getPackagePath(),
203           sourcePath,
204           errors,
205         )
206       }
207     }
208 
209     check(errors.isEmpty()) { errors.joinToString("\n") }
210 
211     ZipOutputStream(BufferedOutputStream(Files.newOutputStream(outputJar))).use { zipper ->
212       commonZipPathToSourcePath.writeToStream(zipper, "common-srcs")
213       ktZipPathToSourcePath.writeToStream(zipper)
214     }
215   }
216 }
217 
218 @Command(name = "unzip", description = ["Unzip a jar archive into a specified directory"])
219 class Unzip : Runnable {
220 
221   @Parameters(index = "0", paramLabel = "inputJar", description = ["Jar archive to unzip"])
222   lateinit var inputJar: Path
223 
224   @Parameters(index = "1", paramLabel = "outputDir", description = ["Output directory"])
225   lateinit var outputDir: Path
226 
runnull227   override fun run() {
228     ZipInputStream(Files.newInputStream(inputJar)).use { unzipper ->
229       while (true) {
230         val zipEntry: ZipEntry? = unzipper.nextEntry
231         if (zipEntry == null) return
232 
233         val entryName = zipEntry.name
234         check(!entryName.contains("./")) { "Cannot unpack srcjar with relative path ${entryName}" }
235 
236         if (!entryName.endsWith(".kt") && !entryName.endsWith(".java")) continue
237 
238         val entryPath = outputDir.resolve(entryName)
239         if (!Files.exists(entryPath.parent)) Files.createDirectories(entryPath.parent)
240         Files.copy(unzipper, entryPath, StandardCopyOption.REPLACE_EXISTING)
241       }
242     }
243   }
244 }
245 
246 @Command(name = "zip_resources", description = ["Zip resources"])
247 class ZipResources : Runnable {
248 
249   @Parameters(index = "0", paramLabel = "outputJar", description = ["Output jar"])
250   lateinit var outputJar: Path
251 
252   @Option(
253     names = ["--input_dirs"],
254     split = ",",
255     description = ["Input files directories"],
256     required = true,
257   )
258   val inputDirs = mutableListOf<Path>()
259 
runnull260   override fun run() {
261     clearSingletonEmptyPath(inputDirs)
262 
263     val filePathToOutputPath = mutableMapOf<Path, Path>()
264     val errors = mutableListOf<String>()
265 
266     // inputDirs has filter checking if the dir exists, because some empty dirs generated by blaze
267     // may not exist from Kotlin compiler's side. It turned out to be safer to apply a filter then
268     // to rely that generated directories are always directories, not just path names
269     for (dirPath in inputDirs.filter { curDirPath -> Files.exists(curDirPath) }) {
270       if (!Files.isDirectory(dirPath)) {
271         errors.add("${dirPath} is not a directory")
272       } else {
273         Files.walk(dirPath)
274           .filter { fileOrDir -> !Files.isDirectory(fileOrDir) }
275           .forEach { filePath ->
276             filePathToOutputPath.checkForDuplicatesAndSetFilePathToPathInsideJar(
277               dirPath.relativize(filePath),
278               filePath,
279               errors
280             )
281           }
282       }
283     }
284 
285     check(errors.isEmpty()) { errors.joinToString("\n") }
286 
287     ZipOutputStream(BufferedOutputStream(Files.newOutputStream(outputJar))).use { zipper ->
288       filePathToOutputPath.writeToStream(zipper)
289     }
290   }
291 }
292