Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0677924a64 | ||
|
|
33477f284b |
@@ -54,13 +54,9 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated
|
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||||
/// API rather than writing host paths with the backend's unrelated UID. Its
|
|
||||||
/// override=false upload atomically refuses existing names, including races.
|
|
||||||
async fn file_purchase_in_files(
|
async fn file_purchase_in_files(
|
||||||
client: &reqwest::Client,
|
data_dir: &std::path::Path,
|
||||||
base_url: &str,
|
|
||||||
token: &str,
|
|
||||||
filename: &str,
|
filename: &str,
|
||||||
mime: &str,
|
mime: &str,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
@@ -72,59 +68,24 @@ async fn file_purchase_in_files(
|
|||||||
} else {
|
} else {
|
||||||
"Documents"
|
"Documents"
|
||||||
};
|
};
|
||||||
let mut folder_url = reqwest::Url::parse(base_url)?;
|
let root = data_dir.join("filebrowser");
|
||||||
folder_url
|
anyhow::ensure!(
|
||||||
.path_segments_mut()
|
tokio::fs::metadata(&root).await?.is_dir(),
|
||||||
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))?
|
"Files storage is unavailable"
|
||||||
.extend(["api", "resources", folder, ""]);
|
);
|
||||||
let response = client
|
let name = std::path::Path::new(filename)
|
||||||
.get(folder_url.clone())
|
|
||||||
.header("X-Auth", token)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
let response = client
|
|
||||||
.post(folder_url.clone())
|
|
||||||
.header("X-Auth", token)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
if response.status() != reqwest::StatusCode::CONFLICT {
|
|
||||||
response.error_for_status()?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
response.error_for_status()?;
|
|
||||||
}
|
|
||||||
let base = std::path::Path::new(filename)
|
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.filter(|n| !n.is_empty())
|
.filter(|n| !n.is_empty())
|
||||||
.unwrap_or("download");
|
.unwrap_or("download");
|
||||||
let (stem, extension) = match base.rsplit_once('.') {
|
let path =
|
||||||
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||||
_ => (base, String::new()),
|
Ok(format!(
|
||||||
};
|
"{folder}/{}",
|
||||||
for attempt in 1..=100 {
|
path.file_name()
|
||||||
let name = if attempt == 1 {
|
.and_then(|n| n.to_str())
|
||||||
base.to_string()
|
.context("Invalid Files name")?
|
||||||
} else {
|
))
|
||||||
format!("{stem} ({attempt}){extension}")
|
|
||||||
};
|
|
||||||
let mut url = folder_url.clone();
|
|
||||||
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
|
|
||||||
url.query_pairs_mut().append_pair("override", "false");
|
|
||||||
let response = client
|
|
||||||
.post(url)
|
|
||||||
.header("X-Auth", token)
|
|
||||||
.body(bytes.to_vec())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
if response.status() == reqwest::StatusCode::CONFLICT {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
response.error_for_status()?;
|
|
||||||
return Ok(format!("{folder}/{name}"));
|
|
||||||
}
|
|
||||||
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@@ -728,28 +689,8 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// The durable purchased-content cache above is primary. A Files copy
|
// The durable purchased-content cache above is primary. A Files copy
|
||||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||||
let filed = async {
|
let filed =
|
||||||
let auth = self.handle_filebrowser_token().await?;
|
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||||
let token = auth
|
|
||||||
.get("token")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.context("FileBrowser omitted its authentication token")?;
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.no_proxy()
|
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()?;
|
|
||||||
file_purchase_in_files(
|
|
||||||
&client,
|
|
||||||
"http://127.0.0.1:8083",
|
|
||||||
token,
|
|
||||||
&filename,
|
|
||||||
&mime_type,
|
|
||||||
&bytes,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
match filed {
|
match filed {
|
||||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||||
Err(error) => tracing::warn!(
|
Err(error) => tracing::warn!(
|
||||||
|
|||||||
@@ -1,69 +1,4 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use hyper::{
|
|
||||||
service::{make_service_fn, service_fn},
|
|
||||||
Body, Response, Server,
|
|
||||||
};
|
|
||||||
use std::{
|
|
||||||
collections::VecDeque,
|
|
||||||
convert::Infallible,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
struct FilesApi {
|
|
||||||
url: String,
|
|
||||||
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
|
||||||
task: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
impl Drop for FilesApi {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn files_api(statuses: Vec<u16>) -> FilesApi {
|
|
||||||
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
|
|
||||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let history = seen.clone();
|
|
||||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
|
||||||
let address = server.local_addr();
|
|
||||||
let service = make_service_fn(move |_| {
|
|
||||||
let statuses = statuses.clone();
|
|
||||||
let seen = history.clone();
|
|
||||||
async move {
|
|
||||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
|
||||||
let statuses = statuses.clone();
|
|
||||||
let seen = seen.clone();
|
|
||||||
async move {
|
|
||||||
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
|
|
||||||
let method = request.method().to_string();
|
|
||||||
let uri = request.uri().to_string();
|
|
||||||
let body = hyper::body::to_bytes(request.into_body())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.to_vec();
|
|
||||||
seen.lock().unwrap().push((method, uri, body));
|
|
||||||
let status = statuses
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.pop_front()
|
|
||||||
.expect("unexpected extra Files request");
|
|
||||||
Ok::<_, Infallible>(
|
|
||||||
Response::builder()
|
|
||||||
.status(status)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
FilesApi {
|
|
||||||
url: format!("http://{address}"),
|
|
||||||
seen,
|
|
||||||
task: tokio::spawn(async move {
|
|
||||||
server.serve(service).await.unwrap();
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||||
@@ -85,80 +20,37 @@ fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() {
|
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||||
let api = files_api(vec![200, 409, 200]);
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let client = reqwest::Client::new();
|
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||||
let path = file_purchase_in_files(
|
.await
|
||||||
&client,
|
.unwrap();
|
||||||
&api.url,
|
|
||||||
"test-session",
|
|
||||||
"../my #file?.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"paid bytes",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(path, "Documents/my #file? (2).txt");
|
|
||||||
let seen = api.seen.lock().unwrap();
|
|
||||||
assert_eq!(seen[0].0, "GET");
|
|
||||||
assert_eq!(seen[0].1, "/api/resources/Documents/");
|
|
||||||
assert_eq!(seen.len(), 3);
|
|
||||||
for (_, uri, body) in &seen[1..] {
|
|
||||||
assert!(uri.contains("override=false"));
|
|
||||||
assert!(uri.contains("%23file%3F"));
|
|
||||||
assert!(!uri.contains("../"));
|
|
||||||
assert_eq!(body, b"paid bytes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn files_copy_creates_missing_media_folder() {
|
|
||||||
for (mime, folder) in [
|
for (mime, folder) in [
|
||||||
("image/png", "Photos"),
|
("image/png", "Photos"),
|
||||||
("video/mp4", "Photos"),
|
("video/mp4", "Photos"),
|
||||||
("audio/ogg", "Music"),
|
("audio/mpeg", "Music"),
|
||||||
|
("text/plain", "Documents"),
|
||||||
] {
|
] {
|
||||||
let api = files_api(vec![404, 200, 200]);
|
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||||
let path = file_purchase_in_files(
|
.await
|
||||||
&reqwest::Client::new(),
|
.unwrap();
|
||||||
&api.url,
|
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||||
"test-session",
|
assert_eq!(
|
||||||
"file",
|
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||||
mime,
|
.await
|
||||||
b"bytes",
|
.unwrap(),
|
||||||
)
|
b"paid"
|
||||||
.await
|
);
|
||||||
.unwrap();
|
|
||||||
assert_eq!(path, format!("{folder}/file"));
|
|
||||||
let seen = api.seen.lock().unwrap();
|
|
||||||
assert_eq!(seen[1].0, "POST");
|
|
||||||
assert!(seen[1].1.ends_with('/'));
|
|
||||||
assert!(seen[1].2.is_empty());
|
|
||||||
assert_eq!(seen[2].2, b"bytes");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||||
for statuses in [
|
let dir = tempfile::tempdir().unwrap();
|
||||||
vec![401],
|
assert!(
|
||||||
vec![503],
|
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||||
vec![404, 500],
|
.await
|
||||||
vec![200, 507],
|
.is_err()
|
||||||
vec![200, 403],
|
);
|
||||||
] {
|
assert!(!dir.path().join("filebrowser").exists());
|
||||||
let expected = statuses.len();
|
|
||||||
let api = files_api(statuses);
|
|
||||||
assert!(file_purchase_in_files(
|
|
||||||
&reqwest::Client::new(),
|
|
||||||
&api.url,
|
|
||||||
"test-session",
|
|
||||||
"file.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"bytes"
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.is_err());
|
|
||||||
assert_eq!(api.seen.lock().unwrap().len(), expected);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! starting the container with `--config /data/.filebrowser.json`.
|
//! starting the container with `--config /data/.filebrowser.json`.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::update::host_sudo;
|
use crate::update::host_sudo;
|
||||||
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
|
|||||||
s.replace('\'', "'\\''")
|
s.replace('\'', "'\\''")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save a complete purchase without overwriting any existing directory entry.
|
||||||
|
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
|
||||||
|
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||||
|
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_filename(name: &str) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!name.is_empty()
|
||||||
|
&& name != "."
|
||||||
|
&& name != ".."
|
||||||
|
&& !name.contains(['/', '\\', '\0'])
|
||||||
|
&& name.len() <= 255,
|
||||||
|
"Invalid purchased filename"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_new_file_with<F, Fut>(
|
||||||
|
dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
fallback: F,
|
||||||
|
) -> Result<PathBuf>
|
||||||
|
where
|
||||||
|
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||||
|
{
|
||||||
|
validate_filename(name)?;
|
||||||
|
// Never follow a user-created destination directory symlink.
|
||||||
|
match fs::symlink_metadata(dir).await {
|
||||||
|
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
save_after_direct_result(
|
||||||
|
write_direct(dir, name, bytes).await,
|
||||||
|
dir,
|
||||||
|
name,
|
||||||
|
bytes,
|
||||||
|
fallback,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_after_direct_result<F, Fut>(
|
||||||
|
result: std::io::Result<PathBuf>,
|
||||||
|
dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
fallback: F,
|
||||||
|
) -> Result<PathBuf>
|
||||||
|
where
|
||||||
|
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||||
|
{
|
||||||
|
match result {
|
||||||
|
Ok(path) => Ok(path),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||||
|
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
|
||||||
|
.await
|
||||||
|
.context("Saving purchase in Files user namespace")
|
||||||
|
}
|
||||||
|
Err(error) => Err(error).context("Saving purchase in Files"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numbered_name(name: &str, attempt: usize) -> String {
|
||||||
|
if attempt == 1 {
|
||||||
|
return name.to_owned();
|
||||||
|
}
|
||||||
|
match name.rsplit_once('.') {
|
||||||
|
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
|
||||||
|
_ => format!("{name} ({attempt})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingFile(PathBuf);
|
||||||
|
impl Drop for PendingFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
fs::create_dir_all(dir).await?;
|
||||||
|
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temp_path)
|
||||||
|
.await?;
|
||||||
|
let temp = PendingFile(temp_path);
|
||||||
|
file.write_all(bytes).await?;
|
||||||
|
file.set_permissions(std::fs::Permissions::from_mode(0o644))
|
||||||
|
.await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
for attempt in 1..=100 {
|
||||||
|
let target = dir.join(numbered_name(name, attempt));
|
||||||
|
match fs::hard_link(&temp.0, &target).await {
|
||||||
|
Ok(()) => return Ok(target),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::AlreadyExists,
|
||||||
|
"Too many existing copies; purchase cache retained",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
|
||||||
|
// collisions; ln -T refuses files, symlinks and directories, including races.
|
||||||
|
const WRITE_VIA_USERNS: &str = r#"set -eu
|
||||||
|
dir=$1
|
||||||
|
name=$2
|
||||||
|
expected=$3
|
||||||
|
[ ! -L "$dir" ] || exit 1
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
mkdir -p -- "$dir"
|
||||||
|
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||||
|
fi
|
||||||
|
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
|
||||||
|
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
|
||||||
|
cat > "$tmp"
|
||||||
|
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
|
||||||
|
chown --reference="$dir" -- "$tmp"
|
||||||
|
chmod 0644 -- "$tmp"
|
||||||
|
sync -f -- "$tmp"
|
||||||
|
stem=$name
|
||||||
|
ext=
|
||||||
|
case "$name" in
|
||||||
|
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
|
||||||
|
esac
|
||||||
|
n=1
|
||||||
|
while [ "$n" -le 100 ]; do
|
||||||
|
candidate=$name
|
||||||
|
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
|
||||||
|
dst="$dir/$candidate"
|
||||||
|
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
|
||||||
|
printf '%s' "$candidate"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# A conflict may be a dangling symlink; never follow it or overwrite it.
|
||||||
|
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
"#;
|
||||||
|
|
||||||
|
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut child = tokio::process::Command::new("podman")
|
||||||
|
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
|
||||||
|
.arg(&dir)
|
||||||
|
.arg(&name)
|
||||||
|
.arg(bytes.len().to_string())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("Starting Files namespace writer")?;
|
||||||
|
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
|
||||||
|
let operation = async {
|
||||||
|
let fed = stdin.write_all(&bytes).await;
|
||||||
|
drop(stdin);
|
||||||
|
let output = child.wait_with_output().await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"Files namespace writer failed: {}",
|
||||||
|
output.status
|
||||||
|
);
|
||||||
|
fed.context("Sending purchase bytes to Files")?;
|
||||||
|
let chosen =
|
||||||
|
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
|
||||||
|
validate_filename(&chosen)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
(1..=100).any(|n| numbered_name(&name, n) == chosen),
|
||||||
|
"Files writer returned an unexpected name"
|
||||||
|
);
|
||||||
|
Ok(dir.join(chosen))
|
||||||
|
};
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
|
||||||
|
.await
|
||||||
|
.context("Files namespace writer timed out")?
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -152,3 +343,231 @@ mod tests {
|
|||||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod purchase_write_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
os::unix::fs::{symlink, PermissionsExt},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn no_temps(dir: &Path) {
|
||||||
|
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
|
||||||
|
.unwrap()
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(".archy-saving")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("song.mp3"), b"original")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
|
||||||
|
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
|
||||||
|
assert_eq!(fs::read(target).await.unwrap(), b"new");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(dir.path().join("song.mp3")).await.unwrap(),
|
||||||
|
b"original"
|
||||||
|
);
|
||||||
|
no_temps(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn simultaneous_saves_publish_unique_complete_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for n in 0..24u8 {
|
||||||
|
let dir = dir.path().to_owned();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
let bytes = vec![n; 32768];
|
||||||
|
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
|
||||||
|
assert_eq!(fs::read(&path).await.unwrap(), bytes);
|
||||||
|
path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let mut paths = HashSet::new();
|
||||||
|
for task in tasks {
|
||||||
|
assert!(paths.insert(task.await.unwrap()));
|
||||||
|
}
|
||||||
|
assert_eq!(paths.len(), 24);
|
||||||
|
no_temps(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||||
|
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||||
|
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
|
||||||
|
assert_eq!(path.file_name().unwrap(), "name (3)");
|
||||||
|
assert!(dir.path().join("name").is_dir());
|
||||||
|
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_symlink());
|
||||||
|
no_temps(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_names_and_symlink_destination_are_refused() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for name in [
|
||||||
|
"",
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
|
"../escape",
|
||||||
|
"/absolute",
|
||||||
|
"a/b",
|
||||||
|
"a\\b",
|
||||||
|
"a\0b",
|
||||||
|
] {
|
||||||
|
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
|
||||||
|
}
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
symlink(outside.path(), dir.path().join("Music")).unwrap();
|
||||||
|
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for n in 1..=100 {
|
||||||
|
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
|
||||||
|
for n in 1..=100 {
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(dir.path().join(numbered_name("a.txt", n)))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"keep"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
no_temps(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn permission_fallback_is_exercised_without_skipping_as_root() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let result = save_after_direct_result(
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||||
|
dir.path(),
|
||||||
|
"a",
|
||||||
|
b"abc",
|
||||||
|
|dir, name, bytes| async move {
|
||||||
|
assert_eq!(bytes, b"abc");
|
||||||
|
Ok(dir.join(name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, dir.path().join("a"));
|
||||||
|
assert!(save_after_direct_result(
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||||
|
dir.path(),
|
||||||
|
"a",
|
||||||
|
b"abc",
|
||||||
|
|_, _, _| async { anyhow::bail!("namespace unavailable") }
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("namespace"));
|
||||||
|
assert!(save_after_direct_result(
|
||||||
|
Err(std::io::ErrorKind::StorageFull.into()),
|
||||||
|
dir.path(),
|
||||||
|
"a",
|
||||||
|
b"abc",
|
||||||
|
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_script(
|
||||||
|
dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
expected: usize,
|
||||||
|
) -> std::process::Output {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut child = tokio::process::Command::new("sh")
|
||||||
|
.args(["-c", WRITE_VIA_USERNS, "sh"])
|
||||||
|
.arg(dir)
|
||||||
|
.arg(name)
|
||||||
|
.arg(expected.to_string())
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let mut input = child.stdin.take().unwrap();
|
||||||
|
input.write_all(bytes).await.unwrap();
|
||||||
|
drop(input);
|
||||||
|
child.wait_with_output().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let folder = dir.path().join("Music");
|
||||||
|
let name = "song ' $() ; #.mp3";
|
||||||
|
for n in 1..=2 {
|
||||||
|
let output = run_script(&folder, name, b"abc", 3).await;
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let chosen = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert_eq!(chosen, numbered_name(name, n));
|
||||||
|
let path = folder.join(chosen);
|
||||||
|
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
|
||||||
|
0o644
|
||||||
|
);
|
||||||
|
}
|
||||||
|
no_temps(&folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(!dir.path().join("never.bin").exists());
|
||||||
|
no_temps(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn namespace_script_does_not_link_inside_existing_directory() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||||
|
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||||
|
let output = run_script(dir.path(), "name", b"abc", 3).await;
|
||||||
|
assert!(output.status.success());
|
||||||
|
assert_eq!(output.stdout, b"name (3)");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
no_temps(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_keep_extensions_and_dotfiles() {
|
||||||
|
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
|
||||||
|
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
|
||||||
|
assert_eq!(numbered_name("README", 2), "README (2)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user