@@ -202,7 +202,149 @@ internal fun stagingStamp(
202202 platform : String ,
203203 freeThreaded : Boolean ,
204204): String = " python-multiplatform $pythonVersion +$pbsRelease $platform " +
205- (if (freeThreaded) " freethreaded" else " default" )
205+ (if (freeThreaded) " freethreaded" else " default" ) +
206+ // Bumped when the extraction itself changes. "links-materialised" (issue #74): Gradle's
207+ // `tarTree` turned the archive's `libpython3.14.so -> libpython3.14.so.1.0` into a 0-byte file,
208+ // so every prefix staged before this is wrong and must be extracted again.
209+ " links-materialised"
210+
211+ /* *
212+ * Extracts a python-build-standalone `.tar.gz` into [root], turning every symbolic and hard link
213+ * into a copy of its in-archive target (issue #74).
214+ *
215+ * Gradle's `tarTree` extracts a symlink as an empty regular file, so `lib/libpython3.14.so` -- a
216+ * link to `libpython3.14.so.1.0` -- came out 0 bytes and `System.load` failed with "file too
217+ * short". A copy rather than a link also survives being copied into a jar or an app image, which
218+ * flatten links. Dependency-free: `GZIPInputStream` plus a minimal ustar/GNU/pax header reader.
219+ * A link whose target is not inside the archive, or that escapes [root], is skipped.
220+ */
221+ internal fun extractTarGzMaterialisingLinks (archive : File , root : File ) {
222+ val rootPath = root.canonicalFile.toPath()
223+ fun inside (rel : String ): java.nio.file.Path ? {
224+ val p = rootPath.resolve(rel).normalize()
225+ return if (p.startsWith(rootPath) && p != rootPath) p else null
226+ }
227+ class Link (val path : java.nio.file.Path , val target : String , val hard : Boolean )
228+ val links = mutableListOf<Link >()
229+
230+ java.util.zip.GZIPInputStream (archive.inputStream().buffered(1 shl 16 )).use { input ->
231+ val header = ByteArray (512 )
232+ var longName: String? = null
233+ var longLink: String? = null
234+ var paxPath: String? = null
235+ var paxLink: String? = null
236+
237+ fun readFully (buf : ByteArray , len : Int ): Boolean {
238+ var n = 0
239+ while (n < len) { val r = input.read(buf, n, len - n); if (r < 0 ) return false ; n + = r }
240+ return true
241+ }
242+ fun str (off : Int , len : Int ): String {
243+ var end = off
244+ while (end < off + len && header[end] != 0 .toByte()) end++
245+ return String (header, off, end - off, Charsets .UTF_8 )
246+ }
247+ fun octal (off : Int , len : Int ): Long = str(off, len).trim().ifEmpty { " 0" }.toLong(8 )
248+ fun readData (size : Long ): ByteArray {
249+ val padded = (size + 511 ) / 512 * 512
250+ val buf = ByteArray (padded.toInt())
251+ check(readFully(buf, buf.size)) { " truncated tar entry in $archive " }
252+ return buf.copyOf(size.toInt())
253+ }
254+
255+ while (true ) {
256+ if (! readFully(header, 512 )) break
257+ if (header.all { it == 0 .toByte() }) break
258+ var name = str(0 , 100 )
259+ val prefix = str(345 , 155 )
260+ if (prefix.isNotEmpty() && str(257 , 5 ) == " ustar" ) name = " $prefix /$name "
261+ val mode = octal(100 , 8 )
262+ val size = octal(124 , 12 )
263+ val type = header[156 ].toInt().toChar()
264+ var linkName = str(157 , 100 )
265+ when (type) {
266+ ' L' -> { longName = String (readData(size), Charsets .UTF_8 ).trimEnd(' \u0000 ' ); continue }
267+ ' K' -> { longLink = String (readData(size), Charsets .UTF_8 ).trimEnd(' \u0000 ' ); continue }
268+ ' x' -> {
269+ String (readData(size), Charsets .UTF_8 ).lineSequence().forEach { rec ->
270+ val kv = rec.substringAfter(' ' , " " )
271+ when {
272+ kv.startsWith(" path=" ) -> paxPath = kv.removePrefix(" path=" )
273+ kv.startsWith(" linkpath=" ) -> paxLink = kv.removePrefix(" linkpath=" )
274+ }
275+ }
276+ continue
277+ }
278+ ' g' -> { readData(size); continue }
279+ }
280+ (paxPath ? : longName)?.let { name = it }
281+ (paxLink ? : longLink)?.let { linkName = it }
282+ longName = null ; longLink = null ; paxPath = null ; paxLink = null
283+
284+ val dest = inside(name.trimEnd(' /' )) ? : run {
285+ if (size > 0 ) readData(size)
286+ null
287+ } ? : continue
288+ when (type) {
289+ ' 5' -> java.nio.file.Files .createDirectories(dest)
290+ ' 2' -> links + = Link (dest, linkName, hard = false )
291+ ' 1' -> links + = Link (dest, linkName, hard = true )
292+ ' 0' , ' \u0000 ' , ' 7' -> {
293+ java.nio.file.Files .createDirectories(dest.parent)
294+ dest.toFile().outputStream().use { out ->
295+ var remaining = size
296+ val buf = ByteArray (1 shl 16 )
297+ while (remaining > 0 ) {
298+ val r = input.read(buf, 0 , minOf(buf.size.toLong(), remaining).toInt())
299+ check(r >= 0 ) { " truncated tar entry $name in $archive " }
300+ out .write(buf, 0 , r); remaining - = r
301+ }
302+ }
303+ var pad = (512 - size % 512 ) % 512
304+ while (pad > 0 ) { val r = input.skip(pad); if (r <= 0 ) { input.read(); pad-- } else pad - = r }
305+ if (mode and 0b001_001_001L != 0L ) dest.toFile().setExecutable(true , false )
306+ }
307+ else -> if (size > 0 ) readData(size)
308+ }
309+ }
310+ }
311+
312+ // Links last, repeated until stable, so a link to a link (or to a later entry) resolves.
313+ var pending: List <Link > = links
314+ while (pending.isNotEmpty()) {
315+ val unresolved = mutableListOf<Link >()
316+ for (link in pending) {
317+ val source = if (link.hard) inside(link.target)
318+ else link.path.parent.resolve(link.target).normalize().takeIf { it.startsWith(rootPath) }
319+ val file = source?.toFile()
320+ if (file == null || ! file.exists() || (file.isFile && pending.any { it.path == source })) {
321+ unresolved + = link; continue
322+ }
323+ if (file.isDirectory) file.copyRecursively(link.path.toFile(), overwrite = true )
324+ else {
325+ java.nio.file.Files .createDirectories(link.path.parent)
326+ file.copyTo(link.path.toFile(), overwrite = true )
327+ link.path.toFile().setExecutable(file.canExecute(), false )
328+ }
329+ }
330+ if (unresolved.size == pending.size) break // remaining targets are outside the archive
331+ pending = unresolved
332+ }
333+ }
334+
335+ /* *
336+ * Fails when a packaged prefix carries a zero-length shared library -- a symlink stub is never a
337+ * library, and shipping one fails at the user's first launch with "file too short" (issue #74).
338+ */
339+ internal fun requireNoEmptyLibraries (dir : File ) {
340+ if (! dir.isDirectory) return
341+ val empty = dir.walkTopDown()
342+ .filter { it.isFile && it.length() == 0L && it.name.startsWith(" libpython" ) }
343+ .map { it.relativeTo(dir).path }.toList()
344+ if (empty.isNotEmpty()) {
345+ throw GradleException (" packaged CPython prefix contains zero-length libraries: $empty (symlink stubs?)" )
346+ }
347+ }
206348
207349/* *
208350 * The directory a packaged desktop application carries its CPython prefix in, inside Compose
@@ -334,10 +476,8 @@ abstract class StagePythonHomeTask : DefaultTask() {
334476 }
335477
336478 logger.lifecycle(" Staging CPython $version ($target ) into $prefix " )
337- fs.copy {
338- from(archives.tarTree(archives.gzip(archive)))
339- into(root)
340- }
479+ // Not `tarTree`: it extracts the archive's symlinks as empty files (issue #74).
480+ extractTarGzMaterialisingLinks(archive, root)
341481
342482 if (! marker.isFile) {
343483 throw GradleException (
0 commit comments