HyukjinKwon commented on code in PR #57814:
URL: https://github.com/apache/spark/pull/57814#discussion_r3727563135
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -3052,6 +3052,17 @@ object SQLConf {
.booleanConf
.createWithDefault(false)
+ val ARCHIVE_READER_MAX_NESTING_DEPTH =
+ buildConf("spark.sql.files.archive.reader.maxNestingDepth")
+ .doc("Maximum number of archive levels an archive reader opens. The
top-level archive is " +
+ "depth 1, so 1 reads no nested archives and 2 reads one level of
nesting. Recursing past " +
Review Comment:
`1 reads no nested archives` describes behavior the code never produces. At
`maxDepth=1` the guard fires on the first nested archive, so the read fails
instead of skipping it -- which the next line of this same doc already says.
Your own test pins the failure (`ArchiveReadSuiteBase.scala:457-459`).
Worth deciding which behavior you want, because it changes whether operators
have an escape hatch. If the limit is purely a zip-bomb bound, this line just
needs to stop implying depth 1 is a no-recursion mode:
```suggestion
"depth 1, so 2 admits one level of nesting. Reading an archive
nested deeper than " +
```
If you instead want a way to keep the pre-PR behavior for a specific read,
that needs a separate knob (or a sentinel that skips nested archives rather
than refusing them) -- I'd keep this config as a pure bound and not overload
it. Line 3059 then reads `"this limit fails, guarding against zip bombs and
cyclic archives.")`.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/SupportsArchiveFormatSuite.scala:
##########
@@ -32,14 +32,17 @@ import
org.apache.commons.compress.archivers.zip.{ZipArchiveEntry, ZipArchiveOut
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.Path
-import org.apache.spark.{SparkFunSuite, SparkRuntimeException, TaskContext,
TaskContextImpl}
+import org.apache.spark.{SparkRuntimeException, TaskContext, TaskContextImpl}
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
/**
* Unit tests for the streaming [[SupportsArchiveFormat]] engine:
`isArchivePath` dispatch and
* `readArchiveEntries` (entry ordering, gzip handling, dir/dotfile skipping,
lazy advance, the
* non-closing entry stream, and cleanup). Nothing here touches local disk --
entries are streams.
*/
-class SupportsArchiveFormatSuite extends SparkFunSuite {
+class SupportsArchiveFormatSuite extends QueryTest with SharedSparkSession {
Review Comment:
The class doc just above (line 43) still says `Nothing here touches local
disk -- entries are streams`, which stopped being true when the nested tests
started exercising the zip/7z spill branch. Worth updating that sentence to say
nested zip/7z entries are spilled to a temp file.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala:
##########
@@ -942,6 +942,12 @@ private[sql] object QueryExecutionErrors extends
QueryErrorsBase with ExecutionE
messageParameters = Map("entry" -> entry, "path" -> path))
}
+ def maxArchiveDepthExceeded(entry: String, maxDepth: Int):
SparkRuntimeException = {
Review Comment:
`SparkRuntimeException` extends `RuntimeException`, and
`DataSourceUtils.shouldIgnoreCorruptFileException` matches every
`RuntimeException` (`DataSourceUtils.scala:237`). So with
`spark.sql.files.ignoreCorruptFiles=true`, exceeding `maxNestingDepth` is
logged as a corrupt file and the rest of the archive is skipped -- the query
succeeds with partial data and no signal that the zip-bomb guard fired. Your
own `ignoreCorruptFiles=true` nested test asserts exactly that swallow for
corrupt bytes (`ArchiveReadSuiteBase.scala:470-471`).
A resource-safety limit is not a corrupt-file condition, so I'd exclude it
explicitly rather than change the exception type. `FileScanRDD.scala:283`
already has that carve-out (`AccessControlException`, `BlockMissingException`);
adding a case ahead of the `ignoreCorruptFiles` clause that rethrows a
`SparkThrowable` whose condition is `MAX_ARCHIVE_DEPTH_EXCEEDED` keeps the
guard authoritative. Whichever way you go, please cover it:
`ignoreCorruptFiles=true` plus a depth-limited nested archive should still fail.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SupportsArchiveFormat.scala:
##########
@@ -255,6 +266,47 @@ object SupportsArchiveFormat {
override def close(): Unit = closeFn()
}
+ /**
+ * Opens an archive nested inside an already-open entry, reading from that
entry's stream.
+ *
+ * @param name the entry's name, which selects the container by extension
+ * @param in the entry's byte stream, positioned at the entry start
+ * @param conf Hadoop configuration used to open a spilled container
+ * @return the nested archive's entries; closing it releases the container
and any spilled file
+ */
+ private def openNestedArchiveStream(
+ name: String,
+ in: InputStream,
+ conf: Configuration): ArchiveEntries = {
+ val n = name.toLowerCase(Locale.ROOT)
+ if (n.endsWith(".tar") || n.endsWith(".tar.gz") || n.endsWith(".tgz")) {
+ val gzipped = n.endsWith(".gz") || n.endsWith(".tgz")
Review Comment:
`endsWith(".gz")` also matches a plain `data.gz`, which is not a tar. That
name only fails to reach here because `isArchiveFileName` doesn't accept `.gz`
-- an invisible coupling between the two methods. If `.gz` is ever added there,
a gzipped non-tar gets handed to `TarArchiveInputStream` and fails confusingly.
```suggestion
val gzipped = n.endsWith(".tar.gz") || n.endsWith(".tgz")
```
This reaches the same decision for every name that can get here, and stands
on its own. (The asymmetry with `openTarStream`'s `.tgz`-only check is fine --
that path gets `.tar.gz` decompressed by `CodecStreams` first, and this one has
no such layer.)
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SupportsArchiveFormat.scala:
##########
@@ -255,6 +266,47 @@ object SupportsArchiveFormat {
override def close(): Unit = closeFn()
}
+ /**
+ * Opens an archive nested inside an already-open entry, reading from that
entry's stream.
+ *
+ * @param name the entry's name, which selects the container by extension
+ * @param in the entry's byte stream, positioned at the entry start
+ * @param conf Hadoop configuration used to open a spilled container
+ * @return the nested archive's entries; closing it releases the container
and any spilled file
+ */
+ private def openNestedArchiveStream(
+ name: String,
+ in: InputStream,
+ conf: Configuration): ArchiveEntries = {
+ val n = name.toLowerCase(Locale.ROOT)
+ if (n.endsWith(".tar") || n.endsWith(".tar.gz") || n.endsWith(".tgz")) {
+ val gzipped = n.endsWith(".gz") || n.endsWith(".tgz")
+ val tar = new TarArchiveInputStream(if (gzipped) new GZIPInputStream(in)
else in)
Review Comment:
`openTarStream` wraps this same construction in a try/catch and explains why
in a comment (line 246-256): `GZIPInputStream` reads the gzip header in its
constructor, so a corrupt member throws here. Nothing unrecoverable leaks on
this path today because the entry stream belongs to the enclosing container,
but the two sibling paths reading differently is the kind of asymmetry that
turns into a leak after the next edit. Mirroring the guard (or a short comment
saying the entry stream is not this method's to close) would settle it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SupportsArchiveFormat.scala:
##########
@@ -367,7 +419,28 @@ object SupportsArchiveFormat {
conf: Configuration,
ignoredPathSegmentRegex: Pattern =
HadoopFSUtils.defaultIgnoredPathSegmentRegexPattern)(
parseEntry: (ArchiveEntry, InputStream) => Iterator[T]): Iterator[T] = {
- val archive = openArchiveStream(path, conf)
+ val maxDepth =
SQLConf.get.getConf(SQLConf.ARCHIVE_READER_MAX_NESTING_DEPTH)
+ streamEntries(
+ openArchiveStream(path, conf), conf, "", 1, maxDepth,
ignoredPathSegmentRegex)(parseEntry)
+ }
+
+ /**
+ * Streams one open `archive`, applying `parseEntry` to each non-skipped
entry. An entry that is
+ * itself an archive recurses instead, bounded by `maxDepth`.
+ *
+ * @param namePrefix nested-entry name prefix ("" at the top, `inner!/`
under a nested archive),
Review Comment:
The recursive call passes `s"${entry.getName}!/"` where `entry` already
carries the incoming prefix, so the value is `outer!/` inside the first nested
archive, never `inner!/`.
```suggestion
* @param namePrefix nested-entry name prefix ("" at the top, `outer!/`
inside the first nested
* archive and `outer!/inner!/` one level deeper),
composing the full
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SupportsArchiveFormat.scala:
##########
@@ -401,8 +474,20 @@ object SupportsArchiveFormat {
done = true
cleanup()
} else {
+ val entry = new PrefixedArchiveEntry(next._1, namePrefix)
// Parse the entry stream; any unread remainder is skipped when
the archive advances.
- currentIter = parseEntry(next._1,
CloseShieldInputStream.wrap(next._2))
+ val stream = CloseShieldInputStream.wrap(next._2)
+ currentIter = if (isArchiveFileName(next._1.getName)) {
+ if (depth >= maxDepth) {
+ throw
QueryExecutionErrors.maxArchiveDepthExceeded(entry.getName, maxDepth)
+ }
+ streamEntries(
+ openNestedArchiveStream(next._1.getName, stream, conf),
Review Comment:
`currentIter` can now be a nested `streamEntries` that owns a container and,
on the zip/7z path, a spilled temp dir -- but `close()` at line 505-509 just
does `currentIter = Iterator.empty` and then `cleanup()`, which closes only
*this* level's archive. So an early close leaks the nested container and its
spill dir. `advance()` already gets this right at line 464-467.
`AvroUtils.firstArchiveEntrySchema` is that caller: it takes one entry's
schema and closes (`AvroUtils.scala:288-296`). It runs on the driver, so there
is no task-completion listener to pick up the slack, and the spill dir then
survives until the shutdown-delete hook runs.
The fix is in `close()` at line 505: match `currentIter` against `Closeable`
and close it before clearing the field, exactly as `advance()` does at 464-467.
A test would fit alongside the existing temp-dir leak cases in
`ArchiveReadSuiteBase.scala:711-731` -- read one row from a nested zip/7z
archive, close, then assert no new dir under the prefix.
##########
common/utils/src/main/resources/error/error-conditions.json:
##########
@@ -5731,6 +5731,12 @@
],
"sqlState" : "42000"
},
+ "MAX_ARCHIVE_DEPTH_EXCEEDED" : {
+ "message" : [
+ "Cannot read archive entry <entry>: it is nested more than <maxDepth>
archives deep."
Review Comment:
The composed entry name (`l2.tar!/l3.tar`) is relative to the top-level
archive, so on a scan over many paths this message doesn't identify the file.
`CANNOT_READ_ZIP_ENTRY` (line 831) includes both, and matching it keeps the two
archive conditions consistent:
```suggestion
"Cannot read archive entry <entry> in archive <path>: it is nested
more than <maxDepth> archives deep."
```
That needs the path threaded to `maxArchiveDepthExceeded` -- `streamEntries`
doesn't currently carry it, so this is a real (if small) plumbing change rather
than a message-only edit. Non-blocking, but the error is much more actionable
with it.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala:
##########
@@ -357,6 +384,99 @@ trait ArchiveReadSuiteBase extends QueryTest with
SharedSparkSession {
}
}
+ // ----- nested-archive tests
------------------------------------------------
+
+ test("a nested archive is recursed into and reads like a directory of its
entries") {
+ val parts = Seq(sampleDf((1, "Alice"), (2, "Bob")), sampleDf((3, "Carol")))
+ val entries = parts.zipWithIndex.map { case (p, i) => entryName(i) ->
encodeFile(p) }
+ withArchiveFile() { archive =>
+ writeNestedArchive(archive, entries)
+ checkAnswer(read(archive.getCanonicalPath), parts.reduce(_ union _))
+ }
+ }
+
+ test("a nested archive alongside plain entries contributes both") {
+ val nested = sampleDf((1, "Alice"), (2, "Bob"))
+ val plain = sampleDf((3, "Carol"))
+ val ext = archiveExtensions.head
+ withArchiveFile() { archive =>
+ writeArchive(archive, Seq(
+ entryName(0) -> encodeFile(plain),
+ s"nested.$ext" -> archiveBytes(Seq(entryName(1) ->
encodeFile(nested)), ext)))
+ checkAnswer(read(archive.getCanonicalPath), plain.union(nested))
+ }
+ }
+
+ test("three levels of nested archives are recursed into") {
+ val data = sampleDf((1, "Alice"), (2, "Bob"))
+ val ext = archiveExtensions.head
+ // archive -> level2.<ext> -> level3.<ext> -> the data file.
+ val level3 = archiveBytes(Seq(entryName(0) -> encodeFile(data)), ext)
+ val level2 = archiveBytes(Seq(s"level3.$ext" -> level3), ext)
+ withArchiveFile() { archive =>
+ writeArchive(archive, Seq(s"level2.$ext" -> level2))
+ checkAnswer(read(archive.getCanonicalPath), data)
+ }
+ }
+
+ test("an empty nested archive contributes no rows") {
+ withArchiveFile() { archive =>
+ writeNestedArchive(archive, Seq.empty)
+ checkAnswer(read(archive.getCanonicalPath), Seq.empty[Row])
+ }
+ }
+
+ test("hidden entries inside a nested archive are skipped") {
+ val kept = sampleDf((1, "Alice"), (2, "Bob"))
+ val ext = archiveExtensions.head
+ val innerBytes = archiveBytes(
+ Seq("_SUCCESS" -> "marker".getBytes, entryName(0) -> encodeFile(kept)),
ext)
+ withArchiveFile() { archive =>
+ writeArchive(archive, Seq(s"nested.$ext" -> innerBytes))
+ checkAnswer(read(archive.getCanonicalPath), kept)
+ }
+ }
+
+ test("the depth limit bounds how deeply nested archives are read") {
+ val data = sampleDf((1, "Alice"), (2, "Bob"))
+ val ext = archiveExtensions.head
+ // Three archive levels: the top archive plus two nested ones.
+ val level3 = archiveBytes(Seq(entryName(0) -> encodeFile(data)), ext)
+ val level2 = archiveBytes(Seq(s"level3.$ext" -> level3), ext)
+ withArchiveFile() { archive =>
+ writeArchive(archive, Seq(s"level2.$ext" -> level2))
+ // 3 admits exactly these three levels.
+ withSQLConf(SQLConf.ARCHIVE_READER_MAX_NESTING_DEPTH.key -> "3") {
+ checkAnswer(read(archive.getCanonicalPath), data)
+ }
+ // 2 stops before the innermost archive is opened.
+ withSQLConf(SQLConf.ARCHIVE_READER_MAX_NESTING_DEPTH.key -> "2") {
Review Comment:
Both depth assertions here (and at line 457) accept any `SparkException`, so
they don't distinguish the depth guard from an unrelated failure at the same
point. `checkError` on the wrapped cause pins `MAX_ARCHIVE_DEPTH_EXCEEDED` and
reads no longer than the current `intercept`.
Non-blocking, since `SupportsArchiveFormatSuite` does pin the condition and
both parameters at the engine level -- this would just make the end-to-end test
assert what its name claims.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]