Skip to content

Commit 54ed47b

Browse files
committed
Support zip files as verifiable build sources.
1 parent bc24cff commit 54ed47b

6 files changed

Lines changed: 217 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ async-trait = "0.1.76"
8989
bollard = "0.20.2"
9090
tar = "0.4.46"
9191
flate2 = "1.0.30"
92+
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
9293
serde-aux = "4.1.2"
9394
serde_json = "1.0.82"
9495
serde = "1.0.82"

cmd/soroban-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ futures = "0.3.30"
113113
home = "0.5.9"
114114
flate2 = { workspace = true }
115115
tar = { workspace = true }
116+
zip = { workspace = true }
116117
bytesize = "1.3.0"
117118
humantime = "2.1.0"
118119
phf = { version = "0.11.2", features = ["macros"] }

cmd/soroban-cli/src/commands/contract/build/source_archive.rs

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,53 @@ pub enum Error {
7676
#[error("could not extract source archive: {0}")]
7777
ArchiveExtract(std::io::Error),
7878

79+
#[error("could not extract source archive: {0}")]
80+
ZipExtract(zip::result::ZipError),
81+
7982
#[error(transparent)]
8083
Data(#[from] data::Error),
8184
}
8285

86+
/// Container formats we can extract a source tree from. This only concerns how
87+
/// the tree is packed for transport; the tree itself is always wrapped in a
88+
/// single top-level directory (SEP-58), which callers check after extraction.
89+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90+
pub(crate) enum ArchiveFormat {
91+
/// Gzipped tarball — what `build --verifiable` produces.
92+
TarGz,
93+
/// Zip archive.
94+
Zip,
95+
}
96+
97+
/// Recognized archive extensions and the format each maps to, matched
98+
/// case-insensitively as a suffix of the archive's filename. Single source of
99+
/// truth for both format detection and the "accepted formats" error text.
100+
const ARCHIVE_EXTENSIONS: &[(&str, ArchiveFormat)] = &[
101+
(".tar.gz", ArchiveFormat::TarGz),
102+
(".tgz", ArchiveFormat::TarGz),
103+
(".zip", ArchiveFormat::Zip),
104+
];
105+
106+
impl ArchiveFormat {
107+
/// The format named by `filename`'s extension, or `None` if unrecognized.
108+
pub(crate) fn from_filename(filename: &str) -> Option<Self> {
109+
let lower = filename.to_ascii_lowercase();
110+
ARCHIVE_EXTENSIONS
111+
.iter()
112+
.find(|(ext, _)| lower.ends_with(ext))
113+
.map(|(_, format)| *format)
114+
}
115+
116+
/// Comma-separated list of accepted extensions, for error messages.
117+
pub(crate) fn recognized_extensions() -> String {
118+
ARCHIVE_EXTENSIONS
119+
.iter()
120+
.map(|(ext, _)| *ext)
121+
.collect::<Vec<_>>()
122+
.join(", ")
123+
}
124+
}
125+
83126
/// The source tree's root: always the current working directory. The archive is
84127
/// rooted there as-is — we do NOT search upward for a git repository or anchor on
85128
/// `--manifest-path`'s directory, since for a workspace member the build needs
@@ -305,11 +348,20 @@ pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> {
305348
.map_err(Error::ArchiveExtract)
306349
}
307350

308-
/// Create a fresh temp directory, unpack the gzipped source tarball `bytes` into
309-
/// it, harden its permissions, and return the guard (the tree lives at its
310-
/// `path()`). Shared by `build --verifiable` (builds from the extracted copy)
311-
/// and `verify` (rebuilds from it); `prefix` names the dir so the two are
312-
/// distinguishable on disk.
351+
/// Unpack a zip archive into `dest`. `ZipArchive::extract` sanitizes each
352+
/// entry's path (dropping anything that would escape `dest`), so a hostile
353+
/// archive can't write outside the tempdir.
354+
pub(crate) fn unpack_zip(bytes: &[u8], dest: &Path) -> Result<(), Error> {
355+
zip::ZipArchive::new(std::io::Cursor::new(bytes))
356+
.and_then(|mut archive| archive.extract(dest))
357+
.map_err(Error::ZipExtract)
358+
}
359+
360+
/// Create a fresh temp directory, unpack the source archive `bytes` (in the
361+
/// given `format`) into it, harden its permissions, and return the guard (the
362+
/// tree lives at its `path()`). Shared by `build --verifiable` (builds from the
363+
/// extracted copy) and `verify` (rebuilds from it); `prefix` names the dir so
364+
/// the two are distinguishable on disk.
313365
///
314366
/// The temp dir is created under `<data dir>/tmp`, NOT the OS temp dir: on macOS
315367
/// `$TMPDIR` lives under /var/folders, which container VMs (Docker Desktop,
@@ -321,6 +373,7 @@ pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> {
321373
pub(crate) fn extract_into_hardened_tempdir(
322374
bytes: &[u8],
323375
prefix: &str,
376+
format: ArchiveFormat,
324377
) -> Result<tempfile::TempDir, Error> {
325378
let base = data::data_local_dir()?.join("tmp");
326379
std::fs::create_dir_all(&base).map_err(|source| Error::ArchiveWrite {
@@ -331,7 +384,10 @@ pub(crate) fn extract_into_hardened_tempdir(
331384
.prefix(prefix)
332385
.tempdir_in(&base)
333386
.map_err(Error::ArchiveExtract)?;
334-
unpack_targz(bytes, tmp.path())?;
387+
match format {
388+
ArchiveFormat::TarGz => unpack_targz(bytes, tmp.path())?,
389+
ArchiveFormat::Zip => unpack_zip(bytes, tmp.path())?,
390+
}
335391
enforce_hardened_tree(tmp.path()).map_err(Error::ArchiveExtract)?;
336392
Ok(tmp)
337393
}
@@ -342,6 +398,56 @@ mod tests {
342398
use crate::config::locator::enforce_hardened_tree;
343399
use sha2::{Digest, Sha256};
344400

401+
#[test]
402+
fn archive_format_from_filename() {
403+
assert_eq!(
404+
ArchiveFormat::from_filename("src.tar.gz"),
405+
Some(ArchiveFormat::TarGz)
406+
);
407+
assert_eq!(
408+
ArchiveFormat::from_filename("SRC.TGZ"),
409+
Some(ArchiveFormat::TarGz)
410+
);
411+
assert_eq!(
412+
ArchiveFormat::from_filename("src.zip"),
413+
Some(ArchiveFormat::Zip)
414+
);
415+
assert_eq!(ArchiveFormat::from_filename("src.rar"), None);
416+
assert_eq!(ArchiveFormat::from_filename("src"), None);
417+
// The listed extensions are exactly what the error surfaces.
418+
assert_eq!(
419+
ArchiveFormat::recognized_extensions(),
420+
".tar.gz, .tgz, .zip"
421+
);
422+
}
423+
424+
#[test]
425+
fn unpack_zip_round_trips() {
426+
use std::io::Write;
427+
// Build a small zip with a single top-level `source/` dir.
428+
let mut buf = Vec::new();
429+
{
430+
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
431+
let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
432+
zip.start_file("source/Cargo.toml", opts).unwrap();
433+
zip.write_all(b"# crate").unwrap();
434+
zip.start_file("source/src/lib.rs", opts).unwrap();
435+
zip.write_all(b"// code").unwrap();
436+
zip.finish().unwrap();
437+
}
438+
439+
let dest = tempfile::TempDir::new().unwrap();
440+
unpack_zip(&buf, dest.path()).unwrap();
441+
assert_eq!(
442+
std::fs::read(dest.path().join("source/Cargo.toml")).unwrap(),
443+
b"# crate"
444+
);
445+
assert_eq!(
446+
std::fs::read(dest.path().join("source/src/lib.rs")).unwrap(),
447+
b"// code"
448+
);
449+
}
450+
345451
#[test]
346452
fn is_warned_matches_names_and_dotted_suffixes() {
347453
use std::ffi::OsStr;

cmd/soroban-cli/src/commands/contract/build/verifiable.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,11 @@ fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result<Archi
331331

332332
// Extract and harden, then build from the extracted copy so the WASM is
333333
// produced from exactly the archived bytes.
334-
let tmp = source_archive::extract_into_hardened_tempdir(&bytes, "verifiable-src-")?;
334+
let tmp = source_archive::extract_into_hardened_tempdir(
335+
&bytes,
336+
"verifiable-src-",
337+
source_archive::ArchiveFormat::TarGz,
338+
)?;
335339
let extracted_root = tmp.path().to_path_buf();
336340
Ok(ArchiveResult {
337341
source_sha256: computed,

0 commit comments

Comments
 (0)