use std::sync::Arc;
use serde_json::{Value, json};
use super::Head;
use crate::pipeline::PipelineError;
use crate::pipeline::nodes::shared::file_ref::{is_file_ref, mime_for_filename};
use crate::pipeline::nodes::shared::project_store::open_source;
use crate::platform::services::PlatformService;
use crate::zebfs::model::ZebFsEntryKind;
pub const CODE_NOT_FOUND: &str = "FW_NODE_WEB_RESPONSE_SEND_NOT_FOUND";
pub const STORED_KEY: &str = "stored";
pub fn stored_envelope(
platform: &Arc<PlatformService>,
owner: &str,
project: &str,
source: &Value,
node_store: Option<&str>,
download_name: Option<&str>,
mut head: Head,
) -> Result<Value, PipelineError> {
if source.is_null() {
return Err(PipelineError::new(CODE_NOT_FOUND, "--file or --path resolved to nothing: there is no file to answer"));
}
if !(source.is_string() || is_file_ref(source)) {
return Err(PipelineError::new(
super::CODE_FILE,
"--file is a project path, or a FileRef from fs.file.put / fs.file.get --return file; --path is a store key",
));
}
let Some((store, key)) = open_source(platform, owner, project, source, node_store)? else {
return Err(PipelineError::new(CODE_NOT_FOUND, "--path is empty: there is no file to answer"));
};
let outside = |why: String| PipelineError::new(CODE_NOT_FOUND, format!("'{key}' is not a file in the project's stores: {why}"));
if key.starts_with('/') || key.starts_with('\\') {
return Err(outside("a store key is relative".to_string()));
}
let key = crate::zebfs::normalize_object_path(&key).map_err(|err| outside(err.message))?;
let stat = match store.fs.head(&key) {
Ok(stat) if stat.kind == ZebFsEntryKind::Object => stat,
Ok(_) => return Err(PipelineError::new(CODE_NOT_FOUND, format!("'{key}' in store '{}' is a folder, not a file", store.id))),
Err(err) if err.code == "ZEBFS_NOT_FOUND" => {
return Err(PipelineError::new(CODE_NOT_FOUND, format!("'{key}' is not in store '{}'", store.id)));
}
Err(err) if matches!(err.code, "ZEBFS_INVALID_PATH" | "ZEBFS_RESERVED_PATH") => {
return Err(PipelineError::new(CODE_NOT_FOUND, format!("'{key}' is not a file in store '{}': {}", store.id, err.message)));
}
Err(err) => return Err(PipelineError::new(super::CODE_FILE, format!("'{key}': {err}"))),
};
let leaf = key.rsplit('/').next().unwrap_or(&key).to_string();
let (mime, name, etag) = if is_file_ref(source) {
let promised = source.get("size").and_then(Value::as_u64).unwrap_or_default();
if promised != stat.size {
return Err(PipelineError::new(
super::CODE_FILE,
format!("'{key}' is {} bytes but its FileRef says {promised}: the object changed after the FileRef was made", stat.size),
));
}
let digest = source["sha256"].as_str().unwrap_or_default().trim_start_matches("sha256:");
(
source["mime"].as_str().unwrap_or("application/octet-stream").to_string(),
source["filename"].as_str().filter(|n| !n.trim().is_empty()).unwrap_or(&leaf).to_string(),
format!("\"{digest}\""),
)
} else {
let secs = stat.modified.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0);
(mime_for_filename(&leaf).to_string(), leaf.clone(), format!("W/\"{:x}-{secs:x}\"", stat.size))
};
head.default_header("Content-Type", &mime);
match download_name {
Some(name) => head.default_header("Content-Disposition", &content_disposition("attachment", name)),
None => head.default_header("Content-Disposition", &content_disposition("inline", &name)),
}
head.default_header("Cache-Control", "private, no-cache");
let modified = stat.modified.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
let mut envelope = head.envelope();
envelope.insert(
STORED_KEY.to_string(),
json!({ "store": store.id, "key": key, "size": stat.size, "modified": modified, "etag": etag }),
);
Ok(Value::Object(envelope))
}
pub fn content_disposition(disposition: &str, name: &str) -> String {
let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
let name = if name.is_empty() { "file" } else { name };
let fallback: String = name
.chars()
.map(|c| if c.is_ascii_graphic() && c != '"' && c != '\\' || c == ' ' { c } else { '_' })
.collect();
if fallback == name {
return format!("{disposition}; filename=\"{fallback}\"");
}
let mut encoded = String::new();
for byte in name.bytes() {
if byte.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&byte) {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
format!("{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{encoded}")
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::{CODE_NOT_FOUND, content_disposition, stored_envelope};
use crate::pipeline::nodes::basic::web::response::{CODE_FILE, Head};
use crate::pipeline::nodes::shared::project_store::open_store;
fn head() -> Head {
Head { status: None, headers: Vec::new() }
}
fn header<'a>(envelope: &'a Value, name: &str) -> Option<&'a str> {
envelope["headers"].as_array()?.iter().find(|pair| pair[0] == name).and_then(|pair| pair[1].as_str())
}
#[test]
fn a_stored_file_is_described_not_read_and_a_missing_one_is_not_found() {
let platform = crate::pipeline::nodes::shared::test_platform::test_platform();
let store = open_store(&platform, "demo", "demo", None).expect("store");
store.fs.put("reports/a1.pdf", b"%PDF-1.7 demo").unwrap();
let file = store.file_ref("reports/a1.pdf", "Quarter one.pdf", "application/pdf", b"%PDF-1.7 demo", "fs.file.put", "user");
let digest = file["sha256"].as_str().unwrap().trim_start_matches("sha256:").to_string();
let envelope = stored_envelope(&platform, "demo", "demo", &file, None, None, head()).expect("envelope");
assert_eq!(envelope["stored"]["key"], "reports/a1.pdf");
assert_eq!(envelope["stored"]["size"], 13);
assert_eq!(envelope["stored"]["etag"], json!(format!("\"{digest}\"")));
assert_eq!(header(&envelope, "Content-Type"), Some("application/pdf"));
assert_eq!(header(&envelope, "Content-Disposition"), Some("inline; filename=\"Quarter one.pdf\""));
assert_eq!(header(&envelope, "Cache-Control"), Some("private, no-cache"));
assert!(envelope.get("body_base64").is_none() && envelope.get("text").is_none(), "{envelope}");
let download = stored_envelope(&platform, "demo", "demo", &json!("reports/a1.pdf"), None, Some("q1.pdf"), head()).unwrap();
assert_eq!(header(&download, "Content-Disposition"), Some("attachment; filename=\"q1.pdf\""));
assert!(download["stored"]["etag"].as_str().unwrap().starts_with("W/\""), "a bare key has a weak validator");
for source in [json!(null), json!("reports/none.pdf"), json!("reports"), json!("../escape.txt"), json!("/etc/hosts"), json!(".zebfs/acl.json")] {
let err = stored_envelope(&platform, "demo", "demo", &source, None, None, head()).unwrap_err();
assert_eq!(err.code, CODE_NOT_FOUND, "{source}: {}", err.message);
}
store.fs.put("reports/a1.pdf", b"%PDF-1.7 a longer demo").unwrap();
assert_eq!(stored_envelope(&platform, "demo", "demo", &file, None, None, head()).unwrap_err().code, CODE_FILE);
}
#[test]
fn a_download_name_is_safe_in_both_forms() {
assert_eq!(content_disposition("attachment", "report.pdf"), "attachment; filename=\"report.pdf\"");
assert_eq!(content_disposition("inline", "a b.pdf"), "inline; filename=\"a b.pdf\"");
assert_eq!(
content_disposition("attachment", "say \"hi\".txt"),
"attachment; filename=\"say _hi_.txt\"; filename*=UTF-8''say%20%22hi%22.txt"
);
assert_eq!(
content_disposition("attachment", "résumé.pdf"),
"attachment; filename=\"r_sum_.pdf\"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"
);
assert_eq!(content_disposition("attachment", "../../etc/passwd"), "attachment; filename=\"passwd\"");
let injected = content_disposition("attachment", "a\r\nSet-Cookie: x=1");
assert!(!injected.contains('\r') && !injected.contains('\n'), "{injected}");
assert_eq!(content_disposition("attachment", ""), "attachment; filename=\"file\"");
}
}