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