use crate::session::{CommandBuilder, Error, ImageError, Project, Session};
use std::path::{Path, PathBuf};
pub struct UnshareSession {
root: PathBuf,
_tempdir: Option<tempfile::TempDir>,
cwd: PathBuf,
isolate_network: bool,
}
fn compression_flag(path: &Path) -> Result<Option<&str>, crate::session::Error> {
match path.extension().unwrap().to_str().unwrap() {
"tar" => Ok(None),
"gz" => Ok(Some("-z")),
"bz2" => Ok(Some("-j")),
"xz" => Ok(Some("-J")),
"zst" => Ok(Some("--zstd")),
e => Err(crate::session::Error::SetupFailure(
"unknown extension".to_string(),
format!("unknown extension: {}", e),
)),
}
}
pub fn cached_debian_tarball_path(suite: &str) -> Result<PathBuf, crate::session::Error> {
let arch = std::env::consts::ARCH;
let arch_name = match arch {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
};
let base_cache_dir = dirs::cache_dir()
.ok_or_else(|| crate::session::Error::ImageError(ImageError::NoCachedImage))?;
let cache_dir = base_cache_dir.join("ognibuild").join("images");
let tarball_name = format!("debian-{}-{}.tar.gz", suite, arch_name);
Ok(cache_dir.join(&tarball_name))
}
impl UnshareSession {
pub fn set_isolate_network(&mut self, isolate: bool) {
self.isolate_network = isolate;
}
pub fn cached_debian_session(suite: &str) -> Result<Self, crate::session::Error> {
let tarball_path = cached_debian_tarball_path(suite)?;
if !tarball_path.exists() {
Err(Error::ImageError(ImageError::NoCachedImage))
} else {
log::info!(
"Using cached Debian {} image from: {}",
suite,
tarball_path.display()
);
Self::from_tarball(&tarball_path)
}
}
pub fn from_tarball(path: &Path) -> Result<Self, crate::session::Error> {
let td = tempfile::tempdir().map_err(|e| {
crate::session::Error::SetupFailure("tempdir failed".to_string(), e.to_string())
})?;
let root = td.path();
let f = std::fs::File::open(path).map_err(|e| {
crate::session::Error::SetupFailure("open failed".to_string(), e.to_string())
})?;
for dir in &["proc", "sys", "dev"] {
std::fs::create_dir_all(root.join(dir)).map_err(|e| {
crate::session::Error::SetupFailure(
format!("Failed to create {} directory", dir),
e.to_string(),
)
})?;
}
let output = std::process::Command::new("unshare")
.arg("--map-users=auto")
.arg("--map-groups=auto")
.arg("--fork")
.arg("--pid")
.arg("--mount-proc")
.arg("--net")
.arg("--uts")
.arg("--ipc")
.arg("--wd")
.arg(root)
.arg("--")
.arg("tar")
.arg("x")
.arg(compression_flag(path)?.unwrap_or("--"))
.stdin(std::process::Stdio::from(f))
.stderr(std::process::Stdio::piped())
.output()?;
if !output.status.success() {
let stderr = String::from_utf8(output.stderr).unwrap();
return Err(crate::session::Error::SetupFailure(
"tar failed".to_string(),
stderr,
));
}
let s = Self {
root: root.to_path_buf(),
_tempdir: Some(td),
cwd: std::path::PathBuf::from("/"),
isolate_network: true,
};
s.ensure_current_user()?;
Ok(s)
}
pub fn save_to_tarball(&self, path: &Path) -> Result<(), crate::session::Error> {
let mut child = self.popen(
vec![
"tar",
"c",
"--absolute-names",
"--exclude",
"/dev/*",
"--exclude",
"/proc/*",
"--exclude",
"/sys/*",
compression_flag(path)?.unwrap_or("--"),
"/",
],
Some(std::path::Path::new("/")),
Some("root"),
Some(std::process::Stdio::piped()),
None,
None,
None,
)?;
let f = std::fs::File::create(path).map_err(|e| {
crate::session::Error::SetupFailure("create failed".to_string(), e.to_string())
})?;
let mut writer = std::io::BufWriter::new(f);
std::io::copy(child.stdout.as_mut().unwrap(), &mut writer).map_err(|e| {
crate::session::Error::SetupFailure("copy failed".to_string(), e.to_string())
})?;
if child.wait()?.success() {
Ok(())
} else {
Err(crate::session::Error::SetupFailure(
"tar failed".to_string(),
"tar failed".to_string(),
))
}
}
pub fn bootstrap() -> Result<Self, crate::session::Error> {
bootstrap_debian_tarball("sid", true)
}
pub fn ensure_current_user(&self) -> Result<(), crate::session::Error> {
let user = whoami::username().map_err(|e| {
crate::session::Error::SetupFailure(
"Failed to get current username".to_string(),
e.to_string(),
)
})?;
let uid = nix::unistd::getuid().to_string();
let gid = nix::unistd::getgid().to_string();
match self.check_call(
vec![
"/usr/sbin/groupadd",
"--force",
"--non-unique",
"--gid",
&gid,
user.as_str(),
],
Some(std::path::Path::new("/")),
Some("root"),
None,
) {
Ok(_) => {}
Err(e) => panic!("Error: {:?}", e),
}
let child = self.popen(
vec![
"/usr/sbin/useradd",
"--uid",
&uid,
"--gid",
&gid,
user.as_str(),
],
Some(std::path::Path::new("/")),
Some("root"),
None,
Some(std::process::Stdio::piped()),
None,
None,
)?;
match child.wait_with_output() {
Ok(output) => {
match output.status.code() {
Some(0) => Ok(()),
Some(9) => Ok(()),
Some(4) => Ok(()),
_ => panic!(
"Error: {:?}: {}",
output.status,
String::from_utf8(output.stdout).unwrap()
),
}
}
Err(e) => panic!("Error: {:?}", e),
}
}
pub fn run_argv<'a>(
&'a self,
argv: Vec<&'a str>,
cwd: Option<&'a std::path::Path>,
user: Option<&'a str>,
) -> std::vec::Vec<&'a str> {
let mut ret = vec![
"unshare",
"--map-users=auto",
"--map-groups=auto",
"--fork",
"--pid",
"--mount-proc",
];
if self.isolate_network {
ret.push("--net");
}
ret.extend([
"--uts",
"--ipc",
"--root",
self.root.to_str().unwrap(),
"--wd",
cwd.unwrap_or(&self.cwd).to_str().unwrap(),
]);
if let Some(user) = user {
if user == "root" {
ret.push("--map-root-user")
} else {
ret.push("--map-user");
ret.push(user);
}
} else {
ret.push("--map-current-user")
}
ret.push("--");
ret.extend(argv);
ret
}
fn build_tempdir(&self, user: Option<&str>) -> std::path::PathBuf {
let build_dir = "/build";
self.check_call(vec!["mkdir", "-p", build_dir], None, user, None)
.unwrap();
String::from_utf8(
self.check_output(
vec!["mktemp", "-d", format!("--tmpdir={}", build_dir).as_str()],
Some(std::path::Path::new("/")),
user,
None,
)
.unwrap(),
)
.unwrap()
.trim_end_matches('\n')
.to_string()
.into()
}
}
pub fn create_debian_session_for_testing(
suite: &str,
allow_network: bool,
) -> Result<UnshareSession, crate::session::Error> {
if let Ok(tarball_path) = std::env::var("OGNIBUILD_DEBIAN_TEST_TARBALL") {
let path = Path::new(&tarball_path);
if path.exists() {
log::info!(
"Using Debian test tarball from OGNIBUILD_DEBIAN_TEST_TARBALL: {}",
tarball_path
);
return UnshareSession::from_tarball(path);
} else {
return Err(Error::SetupFailure(
"Tarball not found".to_string(),
format!(
"OGNIBUILD_DEBIAN_TEST_TARBALL points to non-existent file: {}",
tarball_path
),
));
}
}
match UnshareSession::cached_debian_session(suite) {
Ok(session) => {
log::info!("Using cached Debian {} image", suite);
return Ok(session);
}
Err(Error::ImageError(ImageError::NoCachedImage)) => {
log::debug!("No cached image available for Debian {}", suite);
}
Err(Error::ImageError(ImageError::CachedImageNotFound { path })) => {
log::debug!("Cached image not found at {}", path.display());
}
Err(e) => return Err(e), }
if !allow_network {
return Err(Error::ImageError(ImageError::NoCachedImage));
}
log::info!(
"No cached image found, bootstrapping Debian {} test session from network using mmdebstrap",
suite
);
bootstrap_debian_tarball(suite, true)
}
pub fn bootstrap_debian_tarball(
suite: &str,
setup_apt_file: bool,
) -> Result<UnshareSession, crate::session::Error> {
let td = tempfile::tempdir().map_err(|e| {
crate::session::Error::SetupFailure("tempdir failed".to_string(), e.to_string())
})?;
let root = td.path();
let mut cmd = std::process::Command::new("mmdebstrap");
cmd.current_dir(root)
.arg("--mode=unshare")
.arg("--variant=minbase");
if setup_apt_file {
log::info!("Including apt-file in bootstrap (this requires network access)");
cmd.arg("--include=apt-file") .arg("--customize-hook=chroot \"$1\" apt-file update") .arg("--skip=cleanup/apt/lists"); }
cmd.arg("--quiet")
.arg(suite)
.arg(root)
.arg("http://deb.debian.org/debian/");
let status = cmd.status().map_err(|e| {
crate::session::Error::SetupFailure(
"mmdebstrap command not found or failed to execute".to_string(),
format!("Failed to run mmdebstrap (ensure it's installed): {}", e),
)
})?;
if !status.success() {
return Err(crate::session::Error::SetupFailure(
"mmdebstrap failed".to_string(),
format!("mmdebstrap exited with status: {}. This likely requires network access to http://deb.debian.org/debian/", status),
));
}
let s = UnshareSession {
root: root.to_path_buf(),
_tempdir: Some(td),
cwd: std::path::PathBuf::from("/"),
isolate_network: true,
};
s.ensure_current_user()?;
Ok(s)
}
impl Session for UnshareSession {
fn chdir(&mut self, path: &std::path::Path) -> Result<(), crate::session::Error> {
self.cwd = self.cwd.join(path);
Ok(())
}
fn pwd(&self) -> &std::path::Path {
&self.cwd
}
fn external_path(&self, path: &std::path::Path) -> std::path::PathBuf {
if let Ok(rest) = path.strip_prefix("/") {
return self.location().join(rest);
}
self.location()
.join(
self.cwd
.to_string_lossy()
.to_string()
.trim_start_matches('/'),
)
.join(path)
}
fn location(&self) -> std::path::PathBuf {
self.root.clone()
}
fn check_output(
&self,
argv: Vec<&str>,
cwd: Option<&std::path::Path>,
user: Option<&str>,
env: Option<std::collections::HashMap<String, String>>,
) -> Result<Vec<u8>, super::Error> {
let argv = self.run_argv(argv, cwd, user);
let output = std::process::Command::new(argv[0])
.args(&argv[1..])
.stderr(std::process::Stdio::inherit())
.envs(env.unwrap_or_default())
.output();
match output {
Ok(output) => {
if output.status.success() {
Ok(output.stdout)
} else {
Err(Error::CalledProcessError(output.status))
}
}
Err(e) => Err(Error::IoError(e)),
}
}
fn create_home(&self) -> Result<(), super::Error> {
crate::session::create_home(self)
}
fn check_call(
&self,
argv: Vec<&str>,
cwd: Option<&std::path::Path>,
user: Option<&str>,
env: Option<std::collections::HashMap<String, String>>,
) -> Result<(), crate::session::Error> {
let argv = self.run_argv(argv, cwd, user);
let status = std::process::Command::new(argv[0])
.args(&argv[1..])
.envs(env.unwrap_or_default())
.status();
match status {
Ok(status) => {
if status.success() {
Ok(())
} else {
Err(Error::CalledProcessError(status))
}
}
Err(e) => Err(Error::IoError(e)),
}
}
fn exists(&self, path: &std::path::Path) -> bool {
let args = vec!["test", "-e", path.to_str().unwrap()];
self.check_call(args, None, None, None).is_ok()
}
fn mkdir(&self, path: &std::path::Path) -> Result<(), crate::session::Error> {
let args = vec!["mkdir", path.to_str().unwrap()];
self.check_call(args, None, None, None)
}
fn rmtree(&self, path: &std::path::Path) -> Result<(), crate::session::Error> {
let args = vec!["rm", "-rf", path.to_str().unwrap()];
self.check_call(args, None, None, None)
}
fn project_from_directory(
&self,
path: &std::path::Path,
subdir: Option<&str>,
) -> Result<Project, super::Error> {
let subdir = subdir.unwrap_or("package");
let reldir = self.build_tempdir(Some("root"));
let export_directory = self.external_path(&reldir).join(subdir);
let mut options = fs_extra::dir::CopyOptions::new();
options.copy_inside = true; options.content_only = false; options.skip_exist = false; options.overwrite = true; options.buffer_size = 64000; options.depth = 0;
fs_extra::dir::copy(path, &export_directory, &options).unwrap();
Ok(Project::Temporary {
external_path: export_directory,
internal_path: reldir.join(subdir),
td: self.external_path(&reldir),
})
}
fn popen(
&self,
argv: Vec<&str>,
cwd: Option<&std::path::Path>,
user: Option<&str>,
stdout: Option<std::process::Stdio>,
stderr: Option<std::process::Stdio>,
stdin: Option<std::process::Stdio>,
env: Option<&std::collections::HashMap<String, String>>,
) -> Result<std::process::Child, Error> {
let argv = self.run_argv(argv, cwd, user);
let mut binding = std::process::Command::new(argv[0]);
let mut cmd = binding.args(&argv[1..]);
if let Some(env) = env {
cmd = cmd.envs(env);
}
if let Some(stdin) = stdin {
cmd = cmd.stdin(stdin);
}
if let Some(stdout) = stdout {
cmd = cmd.stdout(stdout);
}
if let Some(stderr) = stderr {
cmd = cmd.stderr(stderr);
}
Ok(cmd.spawn()?)
}
fn is_temporary(&self) -> bool {
true
}
#[cfg(feature = "breezy")]
fn project_from_vcs(
&self,
tree: &dyn crate::vcs::DupableTree,
include_controldir: Option<bool>,
subdir: Option<&str>,
) -> Result<Project, Error> {
let reldir = self.build_tempdir(None);
let subdir = subdir.unwrap_or("package");
let export_directory = self.external_path(&reldir).join(subdir);
if !include_controldir.unwrap_or(false) {
tree.export_to(&export_directory, None).unwrap();
} else {
crate::vcs::dupe_vcs_tree(tree, &export_directory).unwrap();
}
Ok(Project::Temporary {
external_path: export_directory,
internal_path: reldir.join(subdir),
td: self.external_path(&reldir),
})
}
fn command<'a>(&'a self, argv: Vec<&'a str>) -> CommandBuilder<'a> {
CommandBuilder::new(self, argv)
}
fn read_dir(&self, path: &std::path::Path) -> Result<Vec<std::fs::DirEntry>, Error> {
std::fs::read_dir(self.external_path(path))
.map_err(Error::IoError)?
.collect::<Result<Vec<_>, _>>()
.map_err(Error::IoError)
}
}
#[cfg(test)]
lazy_static::lazy_static! {
static ref TEST_SESSION: std::sync::Mutex<UnshareSession> = std::sync::Mutex::new(
create_debian_session_for_testing("sid", false)
.expect("Failed to create test session. This requires network access.\nYou can avoid this by:\n - Pre-caching with: ogni cache-env sid\n - Setting: OGNIBUILD_DEBIAN_TEST_TARBALL=/path/to/tarball.tar.xz")
);
}
#[cfg(test)]
pub(crate) fn test_session() -> Option<std::sync::MutexGuard<'static, UnshareSession>> {
if std::env::var("GITHUB_ACTIONS").is_ok() {
return None;
}
match TEST_SESSION.lock() {
Ok(guard) => Some(guard),
Err(poisoned) => {
Some(poisoned.into_inner())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_temporary() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
assert!(session.is_temporary());
}
#[test]
fn test_chdir() {
let mut session = if let Some(session) = test_session() {
session
} else {
return;
};
session.chdir(std::path::Path::new("/")).unwrap();
}
#[test]
fn test_check_output() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
let output = String::from_utf8(
session
.check_output(vec!["ls"], Some(std::path::Path::new("/")), None, None)
.unwrap(),
)
.unwrap();
let dirs = output.split_whitespace().collect::<Vec<&str>>();
assert!(dirs.contains(&"bin"));
assert!(dirs.contains(&"dev"));
assert!(dirs.contains(&"etc"));
assert!(dirs.contains(&"home"));
assert!(dirs.contains(&"lib"));
assert!(dirs.contains(&"usr"));
assert!(dirs.contains(&"proc"));
assert_eq!(
"root",
String::from_utf8(
session
.check_output(vec!["whoami"], None, Some("root"), None)
.unwrap()
)
.unwrap()
.trim_end()
);
assert_eq!(
String::from_utf8(
session
.check_output(vec!["id", "-u"], None, None, None)
.unwrap()
)
.unwrap()
.trim_end(),
String::from_utf8(
session
.check_output(vec!["id", "-u"], None, None, None)
.unwrap()
)
.unwrap()
.trim_end()
);
assert_eq!(
"nobody",
String::from_utf8(
session
.check_output(vec!["whoami"], None, Some("nobody"), None)
.unwrap()
)
.unwrap()
.trim_end()
);
}
#[test]
fn test_check_call() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
session
.check_call(vec!["true"], Some(std::path::Path::new("/")), None, None)
.unwrap();
}
#[test]
fn test_create_home() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
session.create_home().unwrap();
}
fn save_and_reuse(name: &str) {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join(name);
session.save_to_tarball(&path).unwrap();
std::mem::drop(session);
let session = UnshareSession::from_tarball(&path).unwrap();
assert!(session.exists(std::path::Path::new("/bin")));
let output = String::from_utf8(
session
.check_output(vec!["ls"], Some(std::path::Path::new("/")), None, None)
.unwrap(),
)
.unwrap();
let dirs = output.split_whitespace().collect::<Vec<&str>>();
assert!(dirs.contains(&"bin"));
assert!(dirs.contains(&"dev"));
assert!(dirs.contains(&"etc"));
assert!(dirs.contains(&"home"));
assert!(dirs.contains(&"lib"));
}
#[test]
fn test_save_and_reuse() {
save_and_reuse("test.tar");
}
#[test]
fn test_save_and_reuse_gz() {
save_and_reuse("test.tar.gz");
}
#[test]
fn test_mkdir_rmdir() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
let path = std::path::Path::new("/tmp/test");
session.mkdir(path).unwrap();
assert!(session.exists(path));
session.rmtree(path).unwrap();
assert!(!session.exists(path));
}
#[test]
fn test_project_from_directory() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
let tempdir = tempfile::tempdir().unwrap();
std::fs::write(tempdir.path().join("test"), "test").unwrap();
let project = session
.project_from_directory(tempdir.path(), None)
.unwrap();
assert!(project.external_path().exists());
assert!(session.exists(project.internal_path()));
session.rmtree(project.internal_path()).unwrap();
assert!(!session.exists(project.internal_path()));
assert!(!project.external_path().exists());
}
#[test]
fn test_session_works_after_panic() {
if std::env::var("GITHUB_ACTIONS").is_ok() {
return;
}
let session1 = test_session().unwrap();
assert!(session1.exists(std::path::Path::new("/bin")));
std::mem::drop(session1);
let result = std::panic::catch_unwind(|| {
let _session = test_session().unwrap();
panic!("Intentional panic to test recovery");
});
assert!(result.is_err());
let session2 = test_session().unwrap();
assert!(session2.exists(std::path::Path::new("/bin")));
session2
.check_call(vec!["true"], Some(std::path::Path::new("/")), None, None)
.unwrap();
}
#[test]
fn test_cached_debian_session_no_download() {
let result = UnshareSession::cached_debian_session("test-suite-nonexistent");
assert!(result.is_err());
if let Err(err) = result {
assert!(
matches!(
err,
crate::session::Error::ImageError(
crate::session::ImageError::CachedImageNotFound { .. }
| crate::session::ImageError::NoCachedImage
)
),
"Expected CachedImageNotFound error, got {:?}",
err
);
}
}
#[test]
fn test_cached_debian_session_unsupported_arch() {
let arch = std::env::consts::ARCH;
if arch == "x86_64" || arch == "aarch64" {
return;
}
let result = UnshareSession::cached_debian_session("sid");
assert!(result.is_err());
if let Err(err) = result {
assert!(
matches!(
err,
crate::session::Error::ImageError(
crate::session::ImageError::UnsupportedArchitecture { .. }
)
),
"Expected UnsupportedArchitecture error, got {:?}",
err
);
}
}
#[test]
fn test_create_debian_session_with_env_var() {
let temp_dir = tempfile::tempdir().unwrap();
let tarball_path = temp_dir.path().join("test.tar.xz");
std::fs::write(&tarball_path, b"test").unwrap();
std::env::set_var(
"OGNIBUILD_DEBIAN_TEST_TARBALL",
tarball_path.to_str().unwrap(),
);
let result = create_debian_session_for_testing("sid", false);
std::env::remove_var("OGNIBUILD_DEBIAN_TEST_TARBALL");
assert!(result.is_err());
if let Err(err) = result {
assert!(
matches!(err, crate::session::Error::SetupFailure(_, _)),
"Expected SetupFailure from tar extraction, got {:?}",
err
);
}
}
#[test]
fn test_create_debian_session_nonexistent_tarball() {
std::env::set_var(
"OGNIBUILD_DEBIAN_TEST_TARBALL",
"/nonexistent/path/tarball.tar.xz",
);
let result = create_debian_session_for_testing("sid", false);
std::env::remove_var("OGNIBUILD_DEBIAN_TEST_TARBALL");
assert!(result.is_err());
if let Err(err) = result {
match err {
crate::session::Error::SetupFailure(_msg, detail) => {
assert!(
detail.contains("non-existent file"),
"Expected error about non-existent file, got: {}",
detail
);
}
_ => panic!("Expected SetupFailure, got {:?}", err),
}
}
}
#[cfg(not(feature = "debian"))]
#[test]
fn test_cached_debian_session_no_debian_feature() {
let result = UnshareSession::cached_debian_session("sid");
if result.is_err() {
if let Err(err) = result {
assert!(
matches!(err, crate::session::Error::ImageError(_)),
"Expected ImageError, got {:?}",
err
);
}
}
}
#[test]
fn test_popen() {
let session = if let Some(session) = test_session() {
session
} else {
return;
};
let child = session
.popen(
vec!["ls"],
Some(std::path::Path::new("/")),
None,
Some(std::process::Stdio::piped()),
Some(std::process::Stdio::piped()),
Some(std::process::Stdio::piped()),
None,
)
.unwrap();
let output = String::from_utf8(child.wait_with_output().unwrap().stdout).unwrap();
let dirs = output.split_whitespace().collect::<Vec<&str>>();
assert!(dirs.contains(&"etc"));
assert!(dirs.contains(&"home"));
assert!(dirs.contains(&"lib"));
assert!(dirs.contains(&"usr"));
assert!(dirs.contains(&"proc"));
}
#[test]
fn test_set_isolate_network() {
let mut session = UnshareSession {
root: std::path::PathBuf::from("/fakechroot"),
_tempdir: None,
cwd: std::path::PathBuf::from("/"),
isolate_network: true,
};
let argv = session.run_argv(vec!["true"], Some(std::path::Path::new("/")), None);
assert!(argv.contains(&"--net"));
session.set_isolate_network(false);
let argv = session.run_argv(vec!["true"], Some(std::path::Path::new("/")), None);
assert!(!argv.contains(&"--net"));
session.set_isolate_network(true);
let argv = session.run_argv(vec!["true"], Some(std::path::Path::new("/")), None);
assert!(argv.contains(&"--net"));
}
#[test]
fn test_external_path() {
let mut session = if let Some(session) = test_session() {
session
} else {
return;
};
let path = std::path::Path::new("/tmp/test");
assert_eq!(
session.external_path(path),
session.location().join("tmp/test")
);
session.chdir(std::path::Path::new("/tmp")).unwrap();
let path = std::path::Path::new("test");
assert_eq!(
session.external_path(path),
session.location().join("tmp/test")
);
}
}