Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ etk-ops = { package = "fuel-etk-ops", version = "0.3.1-dev" }
extension-trait = "1.0"
fd-lock = "4.0"
filecheck = "0.5"
flate2 = "1.0"
fs_extra = "1.2"
futures = { version = "0.3", default-features = false }
gag = "1.0"
Expand Down
2 changes: 2 additions & 0 deletions forc-pkg/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ansiterm.workspace = true
anyhow.workspace = true
byte-unit.workspace = true
cid.workspace = true
flate2.workspace = true
forc-tracing.workspace = true
forc-util.workspace = true
fuel-abi-types.workspace = true
Expand Down Expand Up @@ -42,6 +43,7 @@ walkdir.workspace = true

[dev-dependencies]
regex = "^1.10.2"
tempfile.workspace = true

[target.'cfg(not(target_os = "macos"))'.dependencies]
sysinfo = "0.29"
Expand Down
185 changes: 166 additions & 19 deletions forc-pkg/src/source/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::{
source,
};
use anyhow::Result;
use flate2::read::GzDecoder;
use forc_tracing::println_action_green;
use futures::TryStreamExt;
use ipfs_api::IpfsApi;
Expand Down Expand Up @@ -130,6 +131,18 @@ impl fmt::Display for Pinned {
}

impl Cid {
fn extract_archive<R: std::io::Read>(&self, reader: R, dst: &Path) -> Result<()> {
let dst_dir = dst.join(self.0.to_string());
std::fs::create_dir_all(&dst_dir)?;
let mut archive = Archive::new(reader);

for entry in archive.entries()? {
let mut entry = entry?;
entry.unpack_in(&dst_dir)?;
}

Ok(())
}
/// Using local node, fetches the content described by this cid.
async fn fetch_with_client(&self, ipfs_client: &IpfsClient, dst: &Path) -> Result<()> {
let cid_path = format!("/ipfs/{}", self.0);
Expand All @@ -140,8 +153,7 @@ impl Cid {
.try_concat()
.await?;
// After collecting bytes of the archive, we unpack it to the dst.
let mut archive = Archive::new(bytes.as_slice());
archive.unpack(dst)?;
self.extract_archive(bytes.as_slice(), dst)?;
Ok(())
}

Expand All @@ -150,19 +162,18 @@ impl Cid {
let client = reqwest::Client::new();
// We request the content to be served to us in tar format by the public gateway.
let fetch_url = format!(
"{}/ipfs/{}?download=true&format=tar&filename={}.tar",
"{}/ipfs/{}?download=true&filename={}.tar.gz",
gateway_url, self.0, self.0
);
let req = client.get(&fetch_url);
let res = req.send().await?;
if !res.status().is_success() {
anyhow::bail!("Failed to fetch from {fetch_url:?}");
}
let bytes: Vec<_> = res.text().await?.bytes().collect();

// After collecting bytes of the archive, we unpack it to the dst.
let mut archive = Archive::new(bytes.as_slice());
archive.unpack(dst)?;
let bytes: Vec<_> = res.bytes().await?.into_iter().collect();
let tar = GzDecoder::new(bytes.as_slice());
// After collecting and decoding bytes of the archive, we unpack it to the dst.
self.extract_archive(tar, dst)?;
Ok(())
}
}
Expand Down Expand Up @@ -225,18 +236,154 @@ fn pkg_cache_dir(cid: &Cid) -> PathBuf {
fn ipfs_client() -> IpfsClient {
IpfsClient::default()
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::io::Cursor;
use tar::Header;
use tempfile::TempDir;

fn create_header(path: &str, size: u64) -> Header {
let mut header = Header::new_gnu();
header.set_path(path).unwrap();
header.set_size(size);
header.set_mode(0o755);
header.set_cksum();
header
}

fn create_test_tar(files: &[(&str, &str)]) -> Vec<u8> {
let mut ar = tar::Builder::new(Vec::new());

// Add root project directory
let header = create_header("test-project/", 0);
ar.append(&header, &mut std::io::empty()).unwrap();

// Add files
for (path, content) in files {
let full_path = format!("test-project/{}", path);
let header = create_header(&full_path, content.len() as u64);
ar.append(&header, content.as_bytes()).unwrap();
}

ar.into_inner().unwrap()
}

fn create_test_cid() -> Cid {
let cid = cid::Cid::from_str("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG").unwrap();
Cid(cid)
}

#[test]
fn test_basic_extraction() -> Result<()> {
let temp_dir = TempDir::new()?;
let cid = create_test_cid();

#[test]
fn test_source_ipfs_pinned_parsing() {
let string = "ipfs+QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG";
let tar_content = create_test_tar(&[("test.txt", "hello world")]);

cid.extract_archive(Cursor::new(tar_content), temp_dir.path())?;

let extracted_path = temp_dir
.path()
.join(cid.0.to_string())
.join("test-project")
.join("test.txt");

assert!(extracted_path.exists());
assert_eq!(std::fs::read_to_string(extracted_path)?, "hello world");

Ok(())
}

let expected = Pinned(Cid(cid::Cid::from_str(
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
)
.unwrap()));
#[test]
fn test_nested_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let cid = create_test_cid();

let parsed = Pinned::from_str(string).unwrap();
assert_eq!(parsed, expected);
let serialized = expected.to_string();
assert_eq!(&serialized, string);
let tar_content =
create_test_tar(&[("src/main.sw", "contract {};"), ("README.md", "# Test")]);

cid.extract_archive(Cursor::new(tar_content), temp_dir.path())?;

let base = temp_dir.path().join(cid.0.to_string()).join("test-project");
assert_eq!(
std::fs::read_to_string(base.join("src/main.sw"))?,
"contract {};"
);
assert_eq!(std::fs::read_to_string(base.join("README.md"))?, "# Test");

Ok(())
}

#[test]
fn test_invalid_tar() {
let temp_dir = TempDir::new().unwrap();
let cid = create_test_cid();

let result = cid.extract_archive(Cursor::new(b"not a tar file"), temp_dir.path());

assert!(result.is_err());
}

#[test]
fn test_source_ipfs_pinned_parsing() {
let string = "ipfs+QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG";
let expected = Pinned(Cid(cid::Cid::from_str(
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
)
.unwrap()));
let parsed = Pinned::from_str(string).unwrap();
assert_eq!(parsed, expected);
let serialized = expected.to_string();
assert_eq!(&serialized, string);
}

#[test]
fn test_path_traversal_prevention() -> Result<()> {
let temp_dir = TempDir::new()?;
let cid = create_test_cid();

// Create a known directory structure
let target_dir = temp_dir.path().join("target");
std::fs::create_dir(&target_dir)?;

// Create our canary file in a known location
let canary_content = "sensitive content";
let canary_path = target_dir.join("canary.txt");
std::fs::write(&canary_path, canary_content)?;

// Create tar with malicious path targeting our specific canary file
let mut header = tar::Header::new_gnu();
let malicious_path = b"../../target/canary.txt";
header.as_gnu_mut().unwrap().name[..malicious_path.len()].copy_from_slice(malicious_path);
header.set_size(17);
header.set_mode(0o644);
header.set_cksum();

let mut ar = tar::Builder::new(Vec::new());
ar.append(&header, b"malicious content".as_slice())?;

// Add safe file
let mut safe_header = tar::Header::new_gnu();
safe_header.set_path("safe.txt")?;
safe_header.set_size(12);
safe_header.set_mode(0o644);
safe_header.set_cksum();
ar.append(&safe_header, b"safe content".as_slice())?;

// Extract to a subdirectory of temp_dir
let tar_content = ar.into_inner()?;
let extract_dir = temp_dir.path().join("extract");
std::fs::create_dir(&extract_dir)?;
cid.extract_archive(Cursor::new(tar_content), &extract_dir)?;

// Verify canary file was not modified
assert_eq!(
std::fs::read_to_string(&canary_path)?,
canary_content,
"Canary file was modified - path traversal protection failed!"
);
Ok(())
}
}
Loading