1- import org.apache.commons.compress.archivers.zip.Zip64Mode
1+ @file:Suppress(" PackageDirectoryMismatch" )
2+
3+ package com.faforever.coopdeployer
4+
5+ import com.faforever.CHECKSUMS_FILENAME
6+ import com.faforever.FafDatabase
7+ import com.faforever.GitRepo
8+ import com.faforever.Log
9+ import com.faforever.extractChecksumsFromZip
10+ import com.faforever.generateChecksums
11+ import com.faforever.md5
212import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
313import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
4- import org.eclipse.jgit.api.Git
514import org.slf4j.LoggerFactory
615import java.io.IOException
716import java.net.URI
@@ -12,16 +21,8 @@ import java.nio.file.Files
1221import java.nio.file.Path
1322import java.nio.file.Paths
1423import java.nio.file.StandardCopyOption
15- import java.nio.file.attribute.FileTime
1624import java.nio.file.attribute.PosixFilePermission
17- import java.security.MessageDigest
18- import java.sql.Connection
19- import java.sql.DriverManager
2025import java.time.Duration
21- import java.util.zip.CRC32
22- import java.util.zip.ZipEntry
23- import java.util.zip.ZipOutputStream
24- import kotlin.io.path.inputStream
2526
2627private val log = LoggerFactory .getLogger(" CoopDeployer" )
2728
@@ -34,33 +35,6 @@ fun Path.setPerm664() {
3435 Files .setPosixFilePermissions(this , perms)
3536}
3637
37- data class FeatureModGitRepo (
38- val workDir : Path ,
39- val repoUrl : String ,
40- val gitRef : String ,
41- ) {
42- fun checkout (): Path {
43- if (Files .exists(workDir.resolve(" .git" ))) {
44- log.info(" Repo exists — fetching and checking out $gitRef ..." )
45- Git .open(workDir.toFile()).use { git ->
46- git.fetch().call()
47- git.checkout().setName(gitRef).call()
48- }
49- } else {
50- log.info(" Cloning repository $repoUrl " )
51- Git .cloneRepository()
52- .setURI(repoUrl)
53- .setDirectory(workDir.toFile())
54- .call()
55- log.info(" Checking out $gitRef " )
56- Git .open(workDir.toFile()).use { git ->
57- git.checkout().setName(gitRef).call()
58- }
59- }
60-
61- return workDir
62- }
63- }
6438
6539data class GithubReleaseAssetDownloader (
6640 val repoOwner : String = " FAForever" ,
@@ -160,25 +134,14 @@ data class GithubReleaseAssetDownloader(
160134
161135}
162136
163- data class FafDatabase (
164- val host : String ,
165- val database : String ,
166- val username : String ,
167- val password : String ,
137+ data class CoopDatabase (
168138 val dryRun : Boolean
169- ) : AutoCloseable {
139+ ) : FafDatabase() {
170140 /* *
171141 * Definition of an existing file in the database
172142 */
173143 data class PatchFile (val mod : String , val fileId : Int , val name : String , val md5 : String , val version : Int )
174144
175- private val connection: Connection =
176- DriverManager .getConnection(
177- " jdbc:mariadb://$host /$database ?useSSL=false&serverTimezone=UTC" ,
178- username,
179- password
180- )
181-
182145 fun getCurrentPatchFile (mod : String , fileId : Int ): PatchFile ? {
183146 val sql = """
184147 SELECT uf.fileId, uf.name, uf.md5, t.v
@@ -191,7 +154,7 @@ data class FafDatabase(
191154 WHERE uf.fileId = ?
192155 """ .trimIndent()
193156
194- connection. prepareStatement(sql).use { stmt ->
157+ prepareStatement(sql).use { stmt ->
195158 stmt.setInt(1 , fileId)
196159 val rs = stmt.executeQuery()
197160 while (rs.next()) {
@@ -213,32 +176,25 @@ data class FafDatabase(
213176 }
214177 val del = " DELETE FROM updates_${mod} _files WHERE fileId=? AND version=?"
215178 val ins = " INSERT INTO updates_${mod} _files (fileId, version, name, md5, obselete) VALUES (?, ?, ?, ?, 0)"
216- connection. prepareStatement(del).use {
179+ prepareStatement(del).use {
217180 it.setInt(1 , fileId)
218181 it.setInt(2 , version)
219182 it.executeUpdate()
220183 }
221- connection. prepareStatement(ins).use {
184+ prepareStatement(ins).use {
222185 it.setInt(1 , fileId)
223186 it.setInt(2 , version)
224187 it.setString(3 , name)
225188 it.setString(4 , md5)
226189 it.executeUpdate()
227190 }
228191 }
229-
230- override fun close () {
231- connection.close()
232- }
233192}
234193
235- private const val MINIMUM_ZIP_DATE = 315532800000L // 1980-01-01
236- private val MINIMUM_ZIP_FILE_TIME = FileTime .fromMillis(MINIMUM_ZIP_DATE )
237-
238194class Patcher (
239195 val patchVersion : Int ,
240196 val targetDir : Path ,
241- val db : FafDatabase ,
197+ val db : CoopDatabase ,
242198 val dryRun : Boolean ,
243199) {
244200 /* *
@@ -315,16 +271,22 @@ class Patcher(
315271
316272 val tmp = Files .createTempFile(" coop" , " .zip" )
317273 log.info(" Zipping sources with base={} -> {}" , base, tmp)
318- zipPreserveStructure(existing, tmp, base)
274+ val newChecksums = zipPreserveStructure(existing, tmp, base)
319275
320- val newMd5 = tmp .md5( )
276+ // Compare checksums .md5 content with existing ZIP (if any )
321277 val oldFile = db.getCurrentPatchFile(mod, fileId)
278+ val existingZip = oldFile?.let { outDir.resolve(it.name) }
279+ val oldChecksums = existingZip?.let { extractChecksumsFromZip(it) }
322280
323- if (newMd5 == oldFile?.md5 ) {
281+ if (newChecksums == oldChecksums ) {
324282 log.info(" {} unchanged from version {}, skipping" , name, oldFile.version)
283+ Files .deleteIfExists(tmp)
325284 return
326285 }
327286
287+ // ZIP content changed - compute MD5 for database record
288+ val newMd5 = tmp.md5()
289+
328290 if (! dryRun) {
329291 log.info(" Moving zip to {}" , target)
330292 Files .move(tmp, target, StandardCopyOption .REPLACE_EXISTING )
@@ -334,21 +296,10 @@ class Patcher(
334296 db.insertOrReplace(mod, fileId, patchVersion, name, newMd5)
335297 } else {
336298 log.info(" [DRYRUN] Would move {} -> {}" , tmp, target)
299+ Files .deleteIfExists(tmp)
337300 }
338301 }
339302
340- private fun Path.md5 (): String {
341- val md = MessageDigest .getInstance(" MD5" )
342- this .inputStream().use { input ->
343- val buf = ByteArray (4096 )
344- var r: Int
345- while (input.read(buf).also { r = it } != - 1 ) {
346- md.update(buf, 0 , r)
347- }
348- }
349- return md.digest().joinToString(" " ) { " %02x" .format(it) }
350- }
351-
352303 private fun Path.commonPath (other : Path ): Path {
353304 val a = toAbsolutePath().normalize()
354305 val b = other.toAbsolutePath().normalize()
@@ -361,30 +312,55 @@ class Patcher(
361312 else a.root.resolve(a.subpath(0 , commonCount))
362313 }
363314
364- private fun zipPreserveStructure (sources : List <Path >, outputFile : Path , base : Path ) {
315+ /* *
316+ * Create a ZIP archive with checksums.md5 embedded for content-based change detection.
317+ * Returns the checksums.md5 content for comparison purposes.
318+ */
319+ private fun zipPreserveStructure (sources : List <Path >, outputFile : Path , base : Path ): String {
365320 Files .createDirectories(outputFile.parent)
366321
322+ // First, collect all files to include
323+ val allFiles = mutableListOf<Path >()
324+ for (src in sources) {
325+ if (! Files .exists(src)) {
326+ log.warn(" Could not find path {}" , src)
327+ continue
328+ }
329+ if (Files .isDirectory(src)) {
330+ Files .walk(src).use { stream ->
331+ stream.filter { Files .isRegularFile(it) }.forEach { allFiles.add(it) }
332+ }
333+ } else {
334+ allFiles.add(src)
335+ }
336+ }
337+
338+ // Sort files for deterministic ordering
339+ allFiles.sortBy { base.relativize(it).toString() }
340+
341+ // Generate checksums.md5 content
342+ val checksums = generateChecksums(allFiles, base)
343+
367344 // Never pass a stream here; this will cause extended local headers to be used, making it incompatible to FA!
368345 ZipArchiveOutputStream (outputFile.toFile()).use { zos ->
369346 zos.setMethod(ZipArchiveEntry .DEFLATED )
370347
371- for (src in sources) {
372- if (! Files .exists(src)) {
373- // skip
374- log.warn(" Could not find path {}" , src)
375- continue
376- }
377- if (Files .isDirectory(src)) {
378- Files .walk(src).use { stream ->
379- stream
380- .filter { Files .isRegularFile(it) }
381- .forEach { zos.pushNormalizedFile(base, it) }
382- }
383- } else {
384- zos.pushNormalizedFile(base, src)
385- }
348+ // Write checksums.md5 as first entry
349+ val checksumBytes = checksums.toByteArray()
350+ val checksumEntry = ZipArchiveEntry (CHECKSUMS_FILENAME ).apply {
351+ size = checksumBytes.size.toLong()
352+ }
353+ zos.putArchiveEntry(checksumEntry)
354+ zos.write(checksumBytes)
355+ zos.closeArchiveEntry()
356+
357+ // Write all files
358+ for (path in allFiles) {
359+ zos.pushNormalizedFile(base, path)
386360 }
387361 }
362+
363+ return checksums
388364 }
389365
390366 private fun ZipArchiveOutputStream.pushNormalizedFile (base : Path , path : Path ) {
@@ -393,13 +369,7 @@ class Patcher(
393369 val archiveName = base.relativize(path).toString().replace(" \\ " , " /" )
394370
395371 // Use the same constructor as the FAF API:
396- val entry = ZipArchiveEntry (path.toFile(), archiveName).apply {
397- // Ensure deterministic times
398- setTime(MINIMUM_ZIP_FILE_TIME )
399- setCreationTime(MINIMUM_ZIP_FILE_TIME )
400- setLastModifiedTime(MINIMUM_ZIP_FILE_TIME )
401- setLastAccessTime(MINIMUM_ZIP_FILE_TIME )
402- }
372+ val entry = ZipArchiveEntry (path.toFile(), archiveName)
403373
404374 this .putArchiveEntry(entry)
405375 Files .newInputStream(path).use { inp -> inp.copyTo(this ) }
@@ -409,22 +379,18 @@ class Patcher(
409379}
410380
411381fun main () {
382+ Log .init ()
383+
412384 val PATCH_VERSION = System .getenv(" PATCH_VERSION" ) ? : error(" PATCH_VERSION required" )
413385 val REPO_URL = System .getenv(" GIT_REPO_URL" ) ? : " https://github.com/FAForever/fa-coop.git"
414386 val GIT_REF = System .getenv(" GIT_REF" ) ? : " v$PATCH_VERSION "
415- val WORKDIR = System .getenv(" GIT_WORKDIR" ) ? : " /tmp/fa-coop-kt "
387+ val WORKDIR = System .getenv(" GIT_WORKDIR" ) ? : " /tmp/fa-coop"
416388 val DRYRUN = (System .getenv(" DRY_RUN" ) ? : " false" ).lowercase() in listOf (" 1" , " true" , " yes" )
417-
418- val DB_HOST = System .getenv(" DATABASE_HOST" ) ? : " localhost"
419- val DB_NAME = System .getenv(" DATABASE_NAME" ) ? : " faf"
420- val DB_USER = System .getenv(" DATABASE_USERNAME" ) ? : " root"
421- val DB_PASS = System .getenv(" DATABASE_PASSWORD" ) ? : " banana"
422-
423389 val TARGET_DIR = Paths .get(" ./legacy-featured-mod-files" )
424390
425391 log.info(" === Kotlin Coop Deployer v{} ===" , PATCH_VERSION )
426392
427- val repo = FeatureModGitRepo (
393+ val repo = GitRepo (
428394 workDir = Paths .get(WORKDIR ),
429395 repoUrl = REPO_URL ,
430396 gitRef = GIT_REF
@@ -476,13 +442,7 @@ fun main() {
476442 Patcher .PatchFile (25 , " FAF_Coop_Operation_Tight_Spot_VO.v%d.nx2" , null ),
477443 )
478444
479- FafDatabase (
480- host = DB_HOST ,
481- database = DB_NAME ,
482- username = DB_USER ,
483- password = DB_PASS ,
484- dryRun = DRYRUN
485- ).use { db ->
445+ CoopDatabase (dryRun = DRYRUN ).use { db ->
486446 val patcher = Patcher (
487447 patchVersion = PATCH_VERSION .toInt(),
488448 targetDir = TARGET_DIR ,
0 commit comments