use super::LocalFsTransport;
use crate::deploy::component_problem;
use crate::deploy::ledger::{
LedgerRoot, RELEASE_RECORD_FILE, RELEASE_RECORD_MAX_BYTES, release_record_location,
release_record_rel,
};
use crate::deploy::projects::ReleaseRecord;
use crate::deploy::transport::local_fs::{Dir, FsError};
use crate::deploy::transport::projects::{
GitEntry, ProjectHost, ReleaseRecordWriter, git_entry_location,
};
use crate::error::CoreError;
impl LocalFsTransport {
fn git_entry_blocking(&self, project: &str) -> Result<GitEntry, CoreError> {
if let Some(why) = component_problem(project) {
return Err(self.fail(format!("the project name {why}")));
}
self.require_data_dir()?;
let projects = self.data_dir.join("projects");
match std::fs::symlink_metadata(&projects) {
Ok(m) if m.file_type().is_symlink() => {
return Err(self.fail("projects is a symlink; symlinks are refused"));
}
Ok(m) if !m.file_type().is_dir() => {
return Err(self.fail("projects is not a directory"));
}
Ok(_) => {}
Err(e) => {
return Err(self.fail(format!(
"projects cannot be inspected ({}); is data_dir the gateway data directory?",
e.kind()
)));
}
}
let project_dir = projects.join(project);
match std::fs::symlink_metadata(&project_dir) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(GitEntry::Absent),
Err(e) => {
return Err(self.fail(format!(
"projects/{project} cannot be inspected ({})",
e.kind()
)));
}
Ok(m) if m.file_type().is_symlink() => {
return Err(self.fail(format!(
"projects/{project} is a symlink; symlinks are refused"
)));
}
Ok(m) if !m.file_type().is_dir() => {
return Err(self.fail(format!("projects/{project} is not a directory")));
}
Ok(_) => {}
}
let location = git_entry_location(project);
match std::fs::symlink_metadata(project_dir.join(".git")) {
Ok(m) => {
let kind = m.file_type();
Ok(if kind.is_symlink() {
GitEntry::Symlink
} else if kind.is_dir() {
GitEntry::Directory
} else if kind.is_file() {
GitEntry::File
} else {
GitEntry::Other
})
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GitEntry::Absent),
Err(e) => Err(self.fail(format!("{location} cannot be inspected ({})", e.kind()))),
}
}
fn read_release_record_blocking(
&self,
gateway: &str,
project: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let location = release_record_location(LedgerRoot::DataDir, gateway, project);
let bad = |problem: String| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
};
for (kind, value) in [("gateway", gateway), ("project", project)] {
if let Some(why) = component_problem(value) {
return Err(bad(format!("the {kind} name {why}")));
}
}
self.read_regular_blocking(
&release_record_rel(gateway, project),
RELEASE_RECORD_MAX_BYTES,
"the release record",
(gateway, project),
&bad,
)
}
}
impl LocalFsTransport {
fn write_release_record_blocking(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError> {
let location = release_record_location(LedgerRoot::DataDir, gateway, project);
let bad = |problem: String| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
};
for (kind, value) in [("gateway", gateway), ("project", project)] {
if let Some(why) = component_problem(value) {
return Err(bad(format!("the {kind} name {why}")));
}
}
let record = ReleaseRecord::parse(bytes, &location, gateway, project)?;
let temp_name = format!("{RELEASE_RECORD_FILE}.tmp-{}", record.run_id);
self.require_data_dir()?;
let failed = |e: FsError| match e {
FsError::Changed => bad(
"a path component is, or became, a symlink or a non-directory; refused".to_string(),
),
other => self.mirror_fail(
"write",
format!("{location} cannot be written: {}", other.describe()),
),
};
let root = Dir::root(&self.data_dir).map_err(failed)?;
let rel = release_record_rel(gateway, project);
let parts: Vec<&str> = rel.split('/').collect();
root.replace_via(&parts, &temp_name, bytes).map_err(failed)
}
}
#[async_trait::async_trait]
impl ReleaseRecordWriter for LocalFsTransport {
async fn write_release_record(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError> {
let this = self.clone();
let (gateway, project, bytes) = (gateway.to_string(), project.to_string(), bytes.to_vec());
tokio::task::spawn_blocking(move || {
this.write_release_record_blocking(&gateway, &project, &bytes)
})
.await
.map_err(|e| CoreError::Internal(format!("local write task failed: {e}")))?
}
}
#[async_trait::async_trait]
impl ProjectHost for LocalFsTransport {
async fn git_entry(&self, project: &str) -> Result<GitEntry, CoreError> {
let this = self.clone();
let project = project.to_string();
tokio::task::spawn_blocking(move || this.git_entry_blocking(&project))
.await
.map_err(|e| CoreError::Internal(format!("local read task failed: {e}")))?
}
async fn read_release_record(
&self,
gateway: &str,
project: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let this = self.clone();
let (gateway, project) = (gateway.to_string(), project.to_string());
tokio::task::spawn_blocking(move || this.read_release_record_blocking(&gateway, &project))
.await
.map_err(|e| CoreError::Internal(format!("local read task failed: {e}")))?
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn host(data: &Path) -> LocalFsTransport {
LocalFsTransport::new(data.to_path_buf(), "nodes.master")
}
fn data_with_projects() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("projects")).expect("projects");
dir
}
#[test]
fn local_git_entry_kinds() {
let dir = data_with_projects();
let projects = dir.path().join("projects");
let t = host(dir.path());
assert_eq!(
t.git_entry_blocking("Nope").expect("absent"),
GitEntry::Absent
);
std::fs::create_dir_all(projects.join("Plain")).expect("plain");
assert_eq!(
t.git_entry_blocking("Plain").expect("plain"),
GitEntry::Absent
);
std::fs::create_dir_all(projects.join("Dir/.git")).expect("dir");
assert_eq!(
t.git_entry_blocking("Dir").expect("dir"),
GitEntry::Directory
);
std::fs::create_dir_all(projects.join("File")).expect("file dir");
std::fs::write(projects.join("File/.git"), b"gitdir: ../x").expect("file");
assert_eq!(t.git_entry_blocking("File").expect("file"), GitEntry::File);
#[cfg(unix)]
{
std::fs::create_dir_all(projects.join("Link")).expect("link dir");
std::os::unix::fs::symlink("/nonexistent/target", projects.join("Link/.git"))
.expect("symlink");
assert_eq!(
t.git_entry_blocking("Link").expect("link"),
GitEntry::Symlink
);
}
}
#[test]
fn local_git_entry_missing_projects_dir_is_transport_failed() {
let dir = tempfile::tempdir().expect("tempdir");
let err = host(dir.path())
.git_entry_blocking("CapApp")
.expect_err("no data/projects");
assert_eq!(err.code(), "transport_failed");
assert!(err.to_string().contains("projects"), "{err}");
let err = host(&dir.path().join("gone"))
.git_entry_blocking("CapApp")
.expect_err("no data_dir");
assert_eq!(err.code(), "transport_failed");
}
#[test]
fn local_git_entry_refuses_a_project_that_is_not_a_directory() {
let dir = data_with_projects();
std::fs::write(dir.path().join("projects/CapApp"), b"x").expect("file");
let err = host(dir.path())
.git_entry_blocking("CapApp")
.expect_err("a file where the project directory belongs");
assert_eq!(err.code(), "transport_failed");
}
#[test]
fn local_git_entry_refuses_unsafe_project_names() {
let dir = data_with_projects();
for bad in ["", ".", "..", "a/b", "a\\b", ".git"] {
let err = host(dir.path())
.git_entry_blocking(bad)
.expect_err("unsafe name");
assert_eq!(err.code(), "transport_failed", "{bad:?}");
}
}
#[cfg(unix)]
#[test]
fn local_git_entry_unreadable_project_dir_is_transport_failed_not_absent() {
use std::os::unix::fs::PermissionsExt;
let dir = data_with_projects();
let project = dir.path().join("projects/CapApp");
std::fs::create_dir_all(project.join(".git")).expect("git dir");
std::fs::set_permissions(&project, std::fs::Permissions::from_mode(0o000)).expect("chmod");
let privileged = std::fs::read_dir(&project).is_ok();
let result = host(dir.path()).git_entry_blocking("CapApp");
std::fs::set_permissions(&project, std::fs::Permissions::from_mode(0o755))
.expect("restore");
if privileged {
assert_eq!(result.expect("privileged probe"), GitEntry::Directory);
} else {
let err = result.expect_err("a denied probe is not an answer");
assert_eq!(err.code(), "transport_failed");
assert!(err.to_string().contains("projects/CapApp/.git"), "{err}");
}
}
fn write_record(data: &Path, gateway: &str, project: &str, bytes: &[u8]) {
let path = data.join(release_record_rel(gateway, project));
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(path, bytes).expect("write");
}
#[test]
fn local_release_record_read() {
let dir = tempfile::tempdir().expect("tempdir");
let t = host(dir.path());
let read = || t.read_release_record_blocking("gw", "CapApp");
assert_eq!(read().expect("no ign-deploy"), None);
std::fs::create_dir_all(dir.path().join("ign-deploy/gw/projects")).expect("mkdir");
assert_eq!(read().expect("no project dir"), None);
std::fs::create_dir_all(dir.path().join("ign-deploy/gw/projects/CapApp")).expect("mkdir");
assert_eq!(read().expect("no file"), None);
write_record(dir.path(), "gw", "CapApp", b"{\"schema_version\":1}");
assert_eq!(
read().expect("file"),
Some(b"{\"schema_version\":1}".to_vec())
);
write_record(
dir.path(),
"gw",
"CapApp",
&vec![b'x'; RELEASE_RECORD_MAX_BYTES + 1],
);
let err = read().expect_err("over the cap");
assert_eq!(err.code(), "deploy_run_invalid");
assert!(
err.to_string()
.contains("ign-deploy/gw/projects/CapApp/release.json"),
"{err}"
);
let file = dir.path().join(release_record_rel("gw", "CapApp"));
std::fs::remove_file(&file).expect("rm");
std::fs::create_dir(&file).expect("dir");
let err = read().expect_err("a directory");
assert_eq!(err.code(), "deploy_run_invalid");
}
#[cfg(unix)]
#[test]
fn local_release_record_refuses_symlinks() {
let dir = tempfile::tempdir().expect("tempdir");
let t = host(dir.path());
let outside = dir.path().join("outside.json");
std::fs::write(&outside, b"{}").expect("outside");
let file = dir.path().join(release_record_rel("gw", "CapApp"));
std::fs::create_dir_all(file.parent().expect("parent")).expect("mkdir");
std::os::unix::fs::symlink(&outside, &file).expect("symlink");
let err = t
.read_release_record_blocking("gw", "CapApp")
.expect_err("a symlinked record");
assert_eq!(err.code(), "deploy_run_invalid");
std::fs::remove_file(&file).expect("rm");
let real = dir.path().join("elsewhere");
std::fs::create_dir_all(&real).expect("real");
std::fs::remove_dir(file.parent().expect("parent")).expect("rmdir");
std::os::unix::fs::symlink(&real, file.parent().expect("parent")).expect("dir link");
let err = t
.read_release_record_blocking("gw", "CapApp")
.expect_err("a symlinked directory");
assert_eq!(err.code(), "deploy_run_invalid");
}
#[test]
fn local_release_record_with_an_unreadable_host_is_not_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let err = host(&dir.path().join("gone"))
.read_release_record_blocking("gw", "CapApp")
.expect_err("no data_dir");
assert_eq!(err.code(), "transport_failed");
}
#[test]
fn local_release_record_refuses_unsafe_names() {
let dir = tempfile::tempdir().expect("tempdir");
for (gateway, project) in [
("..", "CapApp"),
("gw", ".."),
("gw", "a/b"),
("", "CapApp"),
] {
let err = host(dir.path())
.read_release_record_blocking(gateway, project)
.expect_err("unsafe name");
assert_eq!(err.code(), "deploy_run_invalid", "{gateway:?} {project:?}");
}
}
fn record_bytes(gateway: &str, project: &str, release: &str) -> Vec<u8> {
ReleaseRecord {
schema_version: 1,
gateway: gateway.to_string(),
project: project.to_string(),
release: release.to_string(),
artifact_sha256: "a".repeat(64),
run_id: "20261020T101500Z-ab12".to_string(),
deployed_at: "2026-10-20T10:15:00Z".to_string(),
}
.to_bytes()
}
#[test]
fn local_release_record_write_round_trips_and_replaces() {
let dir = tempfile::tempdir().expect("tempdir");
let t = host(dir.path());
let first = record_bytes("gw", "CapApp", "v1.0.0");
t.write_release_record_blocking("gw", "CapApp", &first)
.expect("write");
assert_eq!(
t.read_release_record_blocking("gw", "CapApp")
.expect("read"),
Some(first)
);
let second = record_bytes("gw", "CapApp", "v1.1.0");
t.write_release_record_blocking("gw", "CapApp", &second)
.expect("replace");
assert_eq!(
t.read_release_record_blocking("gw", "CapApp")
.expect("read"),
Some(second)
);
let folder = dir.path().join("ign-deploy/gw/projects/CapApp");
let names: Vec<String> = std::fs::read_dir(&folder)
.expect("folder")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["release.json"], "no temporary file is left");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(folder.join("release.json"))
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn local_release_record_write_uses_a_per_run_temporary_name() {
let dir = tempfile::tempdir().expect("tempdir");
let t = host(dir.path());
let folder = dir.path().join("ign-deploy/gw/projects/CapApp");
std::fs::create_dir_all(&folder).expect("folder");
std::fs::write(folder.join("release.json.ign-new"), b"the shared name").expect("a");
std::fs::write(folder.join("release.json.tmp-other-run"), b"another run").expect("b");
let record = record_bytes("gw", "CapApp", "v1.0.0");
t.write_release_record_blocking("gw", "CapApp", &record)
.expect("write");
assert_eq!(
std::fs::read(folder.join("release.json")).expect("record"),
record
);
assert_eq!(
std::fs::read(folder.join("release.json.ign-new")).expect("kept"),
b"the shared name"
);
assert_eq!(
std::fs::read(folder.join("release.json.tmp-other-run")).expect("kept"),
b"another run"
);
let mut names: Vec<String> = std::fs::read_dir(&folder)
.expect("folder")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(
names,
[
"release.json",
"release.json.ign-new",
"release.json.tmp-other-run"
],
"this run's temporary file is gone"
);
}
#[test]
fn local_release_record_write_fails_closed() {
let dir = tempfile::tempdir().expect("tempdir");
let t = host(dir.path());
let good = record_bytes("gw", "CapApp", "v1.0.0");
for (gateway, project) in [
("..", "CapApp"),
("gw", ".."),
("gw", "a/b"),
("", "CapApp"),
("gw", ".git"),
] {
let err = t
.write_release_record_blocking(gateway, project, &good)
.expect_err("unsafe name");
assert_eq!(err.code(), "deploy_run_invalid", "{gateway:?} {project:?}");
}
for bytes in [
b"{}".to_vec(),
b"not json".to_vec(),
record_bytes("gw", "Other", "v1.0.0"),
vec![b'x'; RELEASE_RECORD_MAX_BYTES + 1],
] {
let err = t
.write_release_record_blocking("gw", "CapApp", &bytes)
.expect_err("not this project's record");
assert_eq!(err.code(), "deploy_run_invalid");
}
assert!(
!dir.path().join("ign-deploy").exists(),
"a refused write creates nothing"
);
let err = host(&dir.path().join("gone"))
.write_release_record_blocking("gw", "CapApp", &good)
.expect_err("no data_dir");
assert_eq!(err.code(), "transport_failed");
}
#[cfg(unix)]
#[test]
fn local_release_record_write_refuses_symlinks() {
let dir = tempfile::tempdir().expect("tempdir");
let t = host(dir.path());
let bytes = record_bytes("gw", "CapApp", "v1.0.0");
let outside = dir.path().join("outside.json");
std::fs::write(&outside, b"keep").expect("outside");
let file = dir.path().join(release_record_rel("gw", "CapApp"));
std::fs::create_dir_all(file.parent().expect("parent")).expect("mkdir");
std::os::unix::fs::symlink(&outside, &file).expect("symlink");
let err = t
.write_release_record_blocking("gw", "CapApp", &bytes)
.expect_err("a symlinked record");
assert_eq!(err.code(), "deploy_run_invalid");
assert_eq!(std::fs::read(&outside).expect("untouched"), b"keep");
std::fs::remove_file(&file).expect("rm");
let real = dir.path().join("elsewhere");
std::fs::create_dir_all(&real).expect("real");
std::fs::remove_dir(file.parent().expect("parent")).expect("rmdir");
std::os::unix::fs::symlink(&real, file.parent().expect("parent")).expect("dir link");
let err = t
.write_release_record_blocking("gw", "CapApp", &bytes)
.expect_err("a symlinked directory");
assert_eq!(err.code(), "deploy_run_invalid");
assert_eq!(
std::fs::read_dir(&real).expect("real").count(),
0,
"nothing was written through the link"
);
}
#[tokio::test]
async fn the_writer_works_through_the_trait() {
let dir = tempfile::tempdir().expect("tempdir");
let w: Box<dyn ReleaseRecordWriter> = Box::new(host(dir.path()));
let bytes = record_bytes("gw", "CapApp", "v1.0.0");
w.write_release_record("gw", "CapApp", &bytes)
.await
.expect("write");
let t = host(dir.path());
assert_eq!(
t.read_release_record("gw", "CapApp").await.expect("read"),
Some(bytes)
);
}
#[tokio::test]
async fn the_host_works_through_the_trait() {
let dir = data_with_projects();
std::fs::create_dir_all(dir.path().join("projects/CapApp/.git")).expect("git");
let t: Box<dyn ProjectHost> = Box::new(host(dir.path()));
assert_eq!(
t.git_entry("CapApp").await.expect("probe"),
GitEntry::Directory
);
assert_eq!(
t.read_release_record("gw", "CapApp").await.expect("record"),
None
);
}
}