Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
113 changes: 101 additions & 12 deletions testcontainers/src/core/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ pub struct CopyToContainerCollection(Vec<CopyToContainer>);

#[derive(Debug, Clone)]
pub struct CopyToContainer {
target: String,
target: CopyTargetOptions,
source: CopyDataSource,
}

#[derive(Debug, Clone)]
pub struct CopyTargetOptions {
target: String,
mode: Option<u32>,
}

#[derive(Debug, Clone)]
pub enum CopyDataSource {
File(PathBuf),
Expand Down Expand Up @@ -52,7 +58,7 @@ impl CopyToContainerCollection {
}

impl CopyToContainer {
pub fn new(source: impl Into<CopyDataSource>, target: impl Into<String>) -> Self {
pub fn new(source: impl Into<CopyDataSource>, target: impl Into<CopyTargetOptions>) -> Self {
Self {
source: source.into(),
target: target.into(),
Expand Down Expand Up @@ -80,6 +86,37 @@ impl CopyToContainer {
}
}

impl CopyTargetOptions {
pub fn new(target: impl Into<String>) -> Self {
Self {
target: target.into(),
mode: None,
}
}

pub fn with_mode(mut self, mode: u32) -> Self {
self.mode = Some(mode);
self
}

pub fn target(&self) -> &str {
&self.target
}

pub fn mode(&self) -> Option<u32> {
self.mode
}
}

impl<T> From<T> for CopyTargetOptions
where
T: Into<String>,
{
fn from(value: T) -> Self {
CopyTargetOptions::new(value.into())
}
}

impl From<&Path> for CopyDataSource {
fn from(value: &Path) -> Self {
CopyDataSource::File(value.to_path_buf())
Expand All @@ -100,21 +137,21 @@ impl CopyDataSource {
pub(crate) async fn append_tar(
&self,
ar: &mut tokio_tar::Builder<Vec<u8>>,
target_path: impl Into<String>,
target: &CopyTargetOptions,
) -> Result<(), CopyToContainerError> {
let target_path: String = target_path.into();
let target_path = target.target();

match self {
CopyDataSource::File(source_file_path) => {
if let Err(e) = append_tar_file(ar, source_file_path, &target_path).await {
if let Err(e) = append_tar_file(ar, source_file_path, target).await {
log::error!(
"Could not append file/dir to tar: {source_file_path:?}:{target_path}"
);
return Err(e);
}
}
CopyDataSource::Data(data) => {
if let Err(e) = append_tar_bytes(ar, data, &target_path).await {
if let Err(e) = append_tar_bytes(ar, data, target).await {
log::error!("Could not append data to tar: {target_path}");
return Err(e);
}
Expand All @@ -128,9 +165,9 @@ impl CopyDataSource {
async fn append_tar_file(
ar: &mut tokio_tar::Builder<Vec<u8>>,
source_file_path: &Path,
target_path: &str,
target: &CopyTargetOptions,
) -> Result<(), CopyToContainerError> {
let target_path = make_path_relative(target_path);
let target_path = make_path_relative(target.target());
let meta = tokio::fs::metadata(source_file_path)
.await
.map_err(CopyToContainerError::IoError)?;
Expand All @@ -144,7 +181,25 @@ async fn append_tar_file(
.await
.map_err(CopyToContainerError::IoError)?;

ar.append_file(target_path, f)
let mut header = tokio_tar::Header::new_gnu();
header.set_size(meta.len());

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = target.mode().unwrap_or_else(|| meta.permissions().mode());
header.set_mode(mode);
}

#[cfg(not(unix))]
{
let mode = target.mode().unwrap_or(0o644);
header.set_mode(mode);
}

header.set_cksum();

ar.append_data(&mut header, target_path, f)
.await
.map_err(CopyToContainerError::IoError)?;
};
Expand All @@ -155,13 +210,13 @@ async fn append_tar_file(
async fn append_tar_bytes(
ar: &mut tokio_tar::Builder<Vec<u8>>,
data: &Vec<u8>,
target_path: &str,
target: &CopyTargetOptions,
) -> Result<(), CopyToContainerError> {
let relative_target_path = make_path_relative(target_path);
let relative_target_path = make_path_relative(target.target());

let mut header = tokio_tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o0644);
header.set_mode(target.mode().unwrap_or(0o0644));
header.set_cksum();

ar.append_data(&mut header, relative_target_path, data.as_slice())
Expand All @@ -184,7 +239,9 @@ fn make_path_relative(path: &str) -> String {
mod tests {
use std::{fs::File, io::Write};

use futures::StreamExt;
use tempfile::tempdir;
use tokio_tar::Archive;

use super::*;

Expand Down Expand Up @@ -248,4 +305,36 @@ mod tests {
let bytes = result.unwrap();
assert!(!bytes.is_empty());
}

#[tokio::test]
async fn tar_bytes_respects_custom_mode() {
let data = vec![1, 2, 3];
let target = CopyTargetOptions::new("data.bin").with_mode(0o600);
let copy_to_container = CopyToContainer::new(data, target);

let tar_bytes = copy_to_container.tar().await.unwrap();
let mut archive = Archive::new(std::io::Cursor::new(tar_bytes));
let mut entries = archive.entries().unwrap();
let entry = entries.next().await.unwrap().unwrap();

assert_eq!(entry.header().mode().unwrap(), 0o600);
}

#[tokio::test]
async fn tar_file_respects_custom_mode() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("file.txt");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "TEST").unwrap();

let target = CopyTargetOptions::new("file.txt").with_mode(0o640);
let copy_to_container = CopyToContainer::new(file_path, target);

let tar_bytes = copy_to_container.tar().await.unwrap();
let mut archive = Archive::new(std::io::Cursor::new(tar_bytes));
let mut entries = archive.entries().unwrap();
let entry = entries.next().await.unwrap().unwrap();

assert_eq!(entry.header().mode().unwrap(), 0o640);
}
}
35 changes: 30 additions & 5 deletions testcontainers/src/core/image/image_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use bollard::models::ResourcesUlimits;

use crate::{
core::{
copy::{CopyDataSource, CopyToContainer},
copy::{CopyDataSource, CopyTargetOptions, CopyToContainer},
healthcheck::Healthcheck,
logs::consumer::LogConsumer,
CgroupnsMode, ContainerPort, Host, Mount, PortMapping, WaitFor,
Expand Down Expand Up @@ -115,10 +115,35 @@ pub trait ImageExt<I: Image> {
/// Adds a mount to the container.
fn with_mount(self, mount: impl Into<Mount>) -> ContainerRequest<I>;

/// Copies some source into the container as file
/// Copies data or a file/dir into the container.
///
/// The simplest form mirrors existing behavior:
/// ```rust,no_run
/// use std::path::Path;
/// use testcontainers::{GenericImage, ImageExt};
///
/// let image = GenericImage::new("image", "tag");
/// image.with_copy_to("/app/config.toml", Path::new("./config.toml"));
/// ```
///
/// By default the target mode is derived from the source file's mode on Unix,
/// and falls back to `0o644` on non-Unix platforms.
///
/// To override the mode (or add more target options), wrap the target with
/// [`CopyTargetOptions`]:
/// ```rust,no_run
/// use std::path::Path;
/// use testcontainers::{CopyTargetOptions, GenericImage, ImageExt};
///
/// let image = GenericImage::new("image", "tag");
/// image.with_copy_to(
/// CopyTargetOptions::new("/app/config.toml").with_mode(0o600),
/// Path::new("./config.toml"),
/// );
/// ```
fn with_copy_to(
self,
target: impl Into<String>,
target: impl Into<CopyTargetOptions>,
source: impl Into<CopyDataSource>,
) -> ContainerRequest<I>;

Expand Down Expand Up @@ -367,11 +392,11 @@ impl<RI: Into<ContainerRequest<I>>, I: Image> ImageExt<I> for RI {

fn with_copy_to(
self,
target: impl Into<String>,
target: impl Into<CopyTargetOptions>,
source: impl Into<CopyDataSource>,
) -> ContainerRequest<I> {
let mut container_req = self.into();
let target: String = target.into();
let target = target.into();
container_req
.copy_to_sources
.push(CopyToContainer::new(source, target));
Expand Down
2 changes: 1 addition & 1 deletion testcontainers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub use crate::core::Container;
#[cfg(feature = "reusable-containers")]
pub use crate::core::ReuseDirective;
pub use crate::core::{
copy::{CopyDataSource, CopyToContainer, CopyToContainerError},
copy::{CopyDataSource, CopyTargetOptions, CopyToContainer, CopyToContainerError},
error::TestcontainersError,
BuildableImage, ContainerAsync, ContainerRequest, Healthcheck, Image, ImageExt,
};
Expand Down
86 changes: 85 additions & 1 deletion testcontainers/tests/async_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ use bollard::{
query_parameters::{ListImagesOptions, RemoveImageOptions},
Docker,
};
use tempfile;
use testcontainers::{
core::{
logs::{consumer::logging_consumer::LoggingConsumer, LogFrame},
wait::{ExitWaitStrategy, LogWaitStrategy},
BuildImageOptions, CmdWaitFor, ExecCommand, WaitFor,
},
runners::{AsyncBuilder, AsyncRunner},
GenericBuildableImage, GenericImage, Image, ImageExt,
CopyTargetOptions, GenericBuildableImage, GenericImage, Image, ImageExt,
};
use tokio::io::AsyncReadExt;

Expand Down Expand Up @@ -187,6 +188,89 @@ async fn async_run_exec() -> anyhow::Result<()> {
Ok(())
}

#[tokio::test]
async fn copy_sets_mode_multiple_sources() -> anyhow::Result<()> {
let _ = pretty_env_logger::try_init();

// file source with explicit mode override
let temp_file = tempfile::NamedTempFile::new()?;
tokio::fs::write(temp_file.path(), "secret".as_bytes()).await?;

// directory source inherits permissions from host file (or default fallback on non-unix)
let temp_dir = tempfile::tempdir()?;
let source_dir = temp_dir.path().join("secrets");
tokio::fs::create_dir_all(&source_dir).await?;

let secret_file = source_dir.join("secret.txt");
tokio::fs::write(&secret_file, "top secret".as_bytes()).await?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&secret_file).await?.permissions();
perms.set_mode(0o700);
tokio::fs::set_permissions(&secret_file, perms).await?;
}

// bytes source with explicit mode override
let data = b"bytes-secret".to_vec();

let image = GenericImage::new("alpine", "3.20").with_wait_for(WaitFor::seconds(1));

let container = image
.clone()
.with_cmd(["sleep", "60"])
.with_copy_to(
CopyTargetOptions::new("/tmp/secret.txt").with_mode(0o600),
temp_file.path(),
)
.with_copy_to(CopyTargetOptions::new("/tmp/secrets"), source_dir.as_path())
.with_copy_to(
CopyTargetOptions::new("/tmp/secret.bin").with_mode(0o640),
data,
)
.start()
.await?;

// assert file mode
let mut res = container
.exec(ExecCommand::new([
"sh",
"-c",
"stat -c '%a' /tmp/secret.txt",
]))
.await?;
let stdout = String::from_utf8(res.stdout_to_vec().await?)?;
assert_eq!(stdout.trim(), "600");

// assert dir file mode
let mut res = container
.exec(ExecCommand::new([
"sh",
"-c",
"stat -c '%a' /tmp/secrets/secret.txt",
]))
.await?;
let stdout = String::from_utf8(res.stdout_to_vec().await?)?;
#[cfg(unix)]
assert_eq!(stdout.trim(), "700");
#[cfg(not(unix))]
assert_eq!(stdout.trim(), "644");

// assert bytes mode
let mut res = container
.exec(ExecCommand::new([
"sh",
"-c",
"stat -c '%a' /tmp/secret.bin",
]))
.await?;
let stdout = String::from_utf8(res.stdout_to_vec().await?)?;
assert_eq!(stdout.trim(), "640");

Ok(())
}

#[cfg(feature = "http_wait_plain")]
#[tokio::test]
async fn async_wait_for_http() -> anyhow::Result<()> {
Expand Down
Loading