use std::sync::Arc;
use serde_json::Value;
use crate::pipeline::PipelineError;
use crate::pipeline::model::{DslFlag, DslFlagKind, NodeFieldDef, NodeFieldType, SelectOptionDef};
use crate::platform::services::PlatformService;
use crate::zebfs::{FileBackend, ZebFs};
pub struct NodeStore {
pub id: String,
pub backend: FileBackend,
pub fs: ZebFs,
}
impl NodeStore {
pub fn file_ref(
&self,
rel_path: &str,
filename: &str,
mime: &str,
bytes: &[u8],
origin: &str,
trust: &str,
) -> Value {
super::file_ref::durable_file_ref(self.backend, &self.id, rel_path, filename, mime, bytes, origin, trust)
}
pub fn file_ref_from_file(
&self,
rel_path: &str,
filename: &str,
mime: &str,
path: &std::path::Path,
origin: &str,
trust: &str,
code: &'static str,
) -> Result<Value, PipelineError> {
super::file_ref::durable_file_ref_from_file(self.backend, &self.id, rel_path, filename, mime, path, origin, trust)
.map_err(|err| PipelineError::new(code, format!("digest '{rel_path}': {err}")))
}
}
pub fn open_store(
platform: &Arc<PlatformService>,
owner: &str,
project: &str,
store_id: Option<&str>,
) -> Result<NodeStore, PipelineError> {
let store = platform.file.open_store(owner, project, store_id).map_err(|err| {
let message = match store_id {
Some(id) if !id.trim().is_empty() => format!("store '{}': {}", id.trim(), err.message),
_ => err.message,
};
PipelineError::new("FW_NODE_STORE", message)
})?;
Ok(NodeStore { id: store.id, backend: store.backend, fs: store.fs })
}
pub fn open_source(
platform: &Arc<PlatformService>,
owner: &str,
project: &str,
value: &Value,
node_store: Option<&str>,
) -> Result<Option<(NodeStore, String)>, PipelineError> {
if super::file_ref::is_file_ref(value) {
super::file_ref::validate_file_ref(value)?;
}
let Some(rel) = super::file_ref::zebfs_rel_path_or_string(value)? else {
return Ok(None);
};
let store_id = if super::file_ref::is_file_ref(value) {
value.get("store").and_then(Value::as_str)
} else {
node_store
};
Ok(Some((open_store(platform, owner, project, store_id)?, rel)))
}
pub fn open_from(
platform: &Arc<PlatformService>,
owner: &str,
project: &str,
value: &Value,
node_store: Option<&str>,
flag: &str,
code: &'static str,
) -> Result<(NodeStore, String), PipelineError> {
if value.is_array() {
return Err(PipelineError::new(code, format!("{flag} is a list; name one file, e.g. {{{{ input.files[0] }}}}")));
}
if !(value.is_null() || value.is_string() || super::file_ref::is_file_ref(value)) {
return Err(PipelineError::new(code, format!("{flag} is not a file: give a FileRef, an upload or a store key")));
}
let Some((store, key)) = open_source(platform, owner, project, value, node_store)? else {
return Err(PipelineError::new(code, format!("{flag} is required: a FileRef, an upload or a store key")));
};
let key = crate::zebfs::normalize_object_path(&key)
.map_err(|err| PipelineError::new(code, format!("{flag} '{key}': {}", err.message)))?;
Ok((store, key))
}
pub fn read_repo_file(root: &std::path::Path, rel: &str, code: &'static str) -> Result<Vec<u8>, PipelineError> {
let (rel, path) = repo_path_without_links(root, rel, code)?;
let meta = std::fs::metadata(&path).map_err(|err| PipelineError::new(code, format!("'{rel}': {err}")))?;
if !meta.is_file() {
return Err(PipelineError::new(code, format!("'{rel}' is not a file")));
}
if meta.len() > MAX_NODE_OBJECT_BYTES {
return Err(PipelineError::new(code, format!("'{rel}' is {} bytes, over the {MAX_NODE_OBJECT_BYTES} a node reads at once", meta.len())));
}
std::fs::read(&path).map_err(|err| PipelineError::new(code, format!("'{rel}': {err}")))
}
pub fn repo_file_modified(root: &std::path::Path, rel: &str, code: &'static str) -> Result<Option<i64>, PipelineError> {
let (rel, path) = repo_path_without_links(root, rel, code)?;
let meta = std::fs::metadata(&path).map_err(|err| PipelineError::new(code, format!("'{rel}': {err}")))?;
Ok(meta
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|since| since.as_secs() as i64))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoEntry {
pub name: String,
pub is_dir: bool,
}
pub fn list_repo_folder(root: &std::path::Path, rel: &str, code: &'static str) -> Result<Vec<RepoEntry>, PipelineError> {
let (rel, path) = if rel.trim_matches('/').is_empty() {
(String::new(), root.to_path_buf())
} else {
repo_path_without_links(root, rel, code)?
};
let read = std::fs::read_dir(&path).map_err(|_| PipelineError::new(code, format!("'{rel}' is not a folder in the project")))?;
let mut entries = Vec::new();
for entry in read {
let entry = entry.map_err(|err| PipelineError::new(code, format!("'{rel}': {err}")))?;
let Ok(kind) = entry.file_type() else { continue };
if kind.is_symlink() || !(kind.is_dir() || kind.is_file()) {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_string) else { continue };
entries.push(RepoEntry { name, is_dir: kind.is_dir() });
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
pub fn create_repo_file(root: &std::path::Path, rel: &str, bytes: &[u8], code: &'static str) -> Result<(), PipelineError> {
let rel = crate::zebfs::normalize_object_path(rel.trim_start_matches('/'))
.map_err(|err| PipelineError::new(code, format!("'{rel}': {}", err.message)))?;
let mut path = root.to_path_buf();
let segments: Vec<&str> = rel.split('/').collect();
for (index, segment) in segments.iter().enumerate() {
path.push(segment);
match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(PipelineError::new(code, format!("'{rel}' passes through a link; repository writes never follow one")));
}
Ok(_) if index + 1 == segments.len() => {
return Err(PipelineError::new(code, format!("'{rel}' already exists")));
}
Ok(_) => {}
Err(_) if index + 1 == segments.len() => {}
Err(_) => std::fs::create_dir(&path).map_err(|err| PipelineError::new(code, format!("'{rel}': {err}")))?,
}
}
std::fs::write(&path, bytes).map_err(|err| PipelineError::new(code, format!("'{rel}': {err}")))
}
fn repo_path_without_links(root: &std::path::Path, rel: &str, code: &'static str) -> Result<(String, std::path::PathBuf), PipelineError> {
let rel = crate::zebfs::normalize_object_path(rel.trim_start_matches('/'))
.map_err(|err| PipelineError::new(code, format!("'{rel}': {}", err.message)))?;
let mut path = root.to_path_buf();
for segment in rel.split('/') {
path.push(segment);
let meta = std::fs::symlink_metadata(&path)
.map_err(|_| PipelineError::new(code, format!("'{rel}' is not in the project")))?;
if meta.file_type().is_symlink() {
return Err(PipelineError::new(code, format!("'{rel}' passes through a link; repository reads never follow one")));
}
}
Ok((rel, path))
}
pub fn read_capped(fs: &ZebFs, key: &str, code: &'static str) -> Result<Vec<u8>, PipelineError> {
let stat = fs.head(key).map_err(|err| PipelineError::new(code, format!("'{key}': {err}")))?;
if stat.size > MAX_NODE_OBJECT_BYTES {
return Err(PipelineError::new(
code,
format!("'{key}' is {} bytes, over the {MAX_NODE_OBJECT_BYTES} a node reads at once", stat.size),
));
}
fs.get(key).map(|object| object.bytes).map_err(|err| PipelineError::new(code, format!("'{key}': {err}")))
}
pub const MAX_NODE_OBJECT_BYTES: u64 = 128 * 1024 * 1024;
impl NodeStore {
pub fn read_capped(&self, key: &str, code: &'static str) -> Result<Vec<u8>, PipelineError> {
read_capped(&self.fs, key, code)
}
pub fn stored_ref(&self, key: &str, origin: &str, trust: &str, code: &'static str) -> Result<Value, PipelineError> {
let leaf = key.rsplit('/').next().unwrap_or(key).to_string();
let mime = super::file_ref::mime_for_filename(&leaf);
let local = self.fs.local_path(key).map_err(|err| PipelineError::new(code, err.to_string()))?;
let scratch;
let path = match local {
Some(path) => path,
None => {
scratch = super::store_scratch::StoreScratch::new(code)?;
let path = scratch.local("object");
self.fs.get_to_file(key, &path).map_err(|err| PipelineError::new(code, err.to_string()))?;
path
}
};
self.file_ref_from_file(key, &leaf, mime, &path, origin, trust, code)
}
pub fn delete_named(
&self,
platform: &PlatformService,
owner: &str,
project: &str,
key: &str,
code: &'static str,
) -> Result<(), PipelineError> {
self.fs.delete(key).map_err(|err| PipelineError::new(code, format!("delete '{key}': {err}")))?;
let layout = platform
.file
.ensure_project_layout(owner, project)
.map_err(|err| PipelineError::new(code, err.to_string()))?;
if layout.store_id() == self.id {
crate::platform::services::zebfs_acl::forget(&layout.data_store_dir(), key)
.map_err(|err| PipelineError::new(code, err.to_string()))?;
}
Ok(())
}
}
pub fn target_key(
path: Option<&str>,
folder: &str,
filename: &str,
code: &'static str,
) -> Result<String, PipelineError> {
let raw = match path.map(str::trim).filter(|p| !p.is_empty()) {
Some(path) => path.to_string(),
None => {
let folder = folder.trim().trim_matches('/');
if folder.is_empty() {
filename.to_string()
} else {
format!("{folder}/{filename}")
}
}
};
if raw.starts_with('/') {
return Err(PipelineError::new(code, format!("invalid destination '{raw}': a store key is relative, without a leading '/'")));
}
crate::zebfs::normalize_object_path(&raw)
.map_err(|err| PipelineError::new(code, format!("invalid destination '{raw}': {}", err.message)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnConflict {
Overwrite,
Skip,
Error,
}
impl OnConflict {
pub fn as_str(self) -> &'static str {
match self {
Self::Overwrite => "overwrite",
Self::Skip => "skip",
Self::Error => "error",
}
}
pub fn parse(raw: Option<&str>, default: OnConflict, code: &'static str) -> Result<Self, PipelineError> {
match raw.map(str::trim).unwrap_or("") {
"" => Ok(default),
"overwrite" => Ok(Self::Overwrite),
"skip" => Ok(Self::Skip),
"error" => Ok(Self::Error),
other => Err(PipelineError::new(
code,
format!("--on-conflict '{other}' must be overwrite, skip or error"),
)),
}
}
pub fn allows_tree(self, store: &ZebFs, folder: &str, code: &'static str) -> Result<bool, PipelineError> {
let exists = match store.list(folder) {
Ok(entries) => !entries.is_empty(),
Err(err) if err.code == "ZEBFS_NOT_FOUND" || err.code == "ZEBFS_NOT_PREFIX" => false,
Err(err) => return Err(PipelineError::new(code, format!("'{folder}': {err}"))),
};
match (exists, self) {
(false, _) | (true, Self::Overwrite) => Ok(true),
(true, Self::Skip) => Ok(false),
(true, Self::Error) => Err(PipelineError::new(
code,
format!("'{folder}' is not empty; set --on-conflict overwrite or skip"),
)),
}
}
pub fn allows(self, store: &ZebFs, key: &str, code: &'static str) -> Result<bool, PipelineError> {
let exists = match store.head(key) {
Ok(_) => true,
Err(err) if err.code == "ZEBFS_NOT_FOUND" => false,
Err(err) => return Err(PipelineError::new(code, format!("'{key}': {err}"))),
};
match (exists, self) {
(false, _) | (true, Self::Overwrite) => Ok(true),
(true, Self::Skip) => Ok(false),
(true, Self::Error) => Err(PipelineError::new(
code,
format!("'{key}' already exists; set --on-conflict overwrite or skip"),
)),
}
}
}
pub fn store_flag() -> DslFlag {
DslFlag {
flag: "--store".to_string(),
config_key: "store".to_string(),
description: "The project store to write to: `local`, or the id of an `s3` credential. Saved explicitly when the pipeline is registered (default: the project's default store at that moment)".to_string(),
kind: DslFlagKind::Scalar,
required: false,
..Default::default()
}
}
pub fn on_conflict_flag(default: OnConflict) -> DslFlag {
DslFlag {
flag: "--on-conflict".to_string(),
config_key: "on_conflict".to_string(),
description: format!(
"When the target already exists: overwrite, skip or error (default: {})",
default.as_str()
),
kind: DslFlagKind::Scalar,
required: false,
..Default::default()
}
}
pub fn store_fields(default: OnConflict) -> Vec<NodeFieldDef> {
vec![
NodeFieldDef {
name: "store".to_string(),
label: "Store".to_string(),
field_type: NodeFieldType::Text,
help: Some("`local`, or the id of an s3 credential. Empty: the project's default, saved when the pipeline is registered.".to_string()),
..Default::default()
},
NodeFieldDef {
name: "on_conflict".to_string(),
label: "If the target exists".to_string(),
field_type: NodeFieldType::Select,
help: Some("error stops the run, skip keeps the existing file, overwrite replaces it.".to_string()),
default_value: Some(serde_json::json!(default.as_str())),
options: ["error", "skip", "overwrite"]
.iter()
.map(|value| SelectOptionDef { value: value.to_string(), label: value.to_string() })
.collect(),
..Default::default()
},
]
}
pub fn store_node_kinds() -> &'static std::collections::BTreeSet<String> {
static KINDS: std::sync::OnceLock<std::collections::BTreeSet<String>> = std::sync::OnceLock::new();
KINDS.get_or_init(|| {
crate::pipeline::nodes::builtin_node_definitions()
.into_iter()
.filter(|def| def.dsl_flags.iter().any(|flag| flag.flag == "--store"))
.map(|def| def.kind)
.collect()
})
}
#[cfg(test)]
mod tests {
use super::{OnConflict, target_key};
use crate::zebfs::{LocalZebFs, ZebFs};
#[cfg(unix)]
#[test]
fn a_repository_read_never_follows_a_link_or_climbs_out() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("static")).unwrap();
std::fs::write(dir.path().join("static/a.txt"), b"a").unwrap();
std::os::unix::fs::symlink("/etc", dir.path().join("static/out")).unwrap();
assert_eq!(super::read_repo_file(dir.path(), "static/a.txt", "T").unwrap(), b"a");
assert!(super::read_repo_file(dir.path(), "static/out/hosts", "T").is_err());
assert!(super::read_repo_file(dir.path(), "../etc/hosts", "T").is_err());
assert!(super::read_repo_file(dir.path(), "static", "T").is_err());
}
#[test]
fn a_path_wins_and_every_key_is_normalised_once() {
assert_eq!(target_key(None, "thumbnails", "a.jpg", "T").unwrap(), "thumbnails/a.jpg");
assert!(target_key(Some("/exact/b.jpg"), "thumbnails", "a.jpg", "T").is_err(), "an absolute key is refused");
assert_eq!(target_key(Some("exact/b.jpg"), "thumbnails", "a.jpg", "T").unwrap(), "exact/b.jpg");
assert_eq!(target_key(None, "", "a.jpg", "T").unwrap(), "a.jpg");
assert!(target_key(Some("../escape.jpg"), "x", "y", "T").is_err());
assert!(target_key(None, "a/../../b", "c", "T").is_err());
}
#[test]
fn on_conflict_decides_an_existing_target() {
let dir = tempfile::tempdir().unwrap();
let store = ZebFs::Local(LocalZebFs::new(dir.path().to_path_buf()));
store.put("a.txt", b"1").unwrap();
assert!(OnConflict::Overwrite.allows(&store, "a.txt", "T").unwrap());
assert!(!OnConflict::Skip.allows(&store, "a.txt", "T").unwrap());
assert!(OnConflict::Error.allows(&store, "a.txt", "T").is_err());
assert!(OnConflict::Error.allows(&store, "b.txt", "T").unwrap());
assert_eq!(OnConflict::parse(None, OnConflict::Error, "T").unwrap(), OnConflict::Error);
assert!(OnConflict::parse(Some("replace"), OnConflict::Error, "T").is_err());
}
}