use super::{Budget, EntryKind, SftpTransport, windows_native_path};
use crate::deploy::component_problem;
use crate::deploy::ledger::{
LedgerRoot, RELEASE_RECORD_FILE, RELEASE_RECORD_MAX_BYTES, pre_image_location, pre_image_rel,
project_journal_location, project_journal_rel, release_record_location, release_record_rel,
tag_record_location, tag_record_rel,
};
use crate::deploy::project_run::{
PRE_IMAGE_MAX_BYTES, PROJECT_JOURNAL_FILE, PROJECT_JOURNAL_MAX_BYTES, ProjectJournal,
};
use crate::deploy::projects::ReleaseRecord;
use crate::deploy::tag_record::{TAG_RECORD_FILE, TAG_RECORD_MAX_BYTES, TagRecord};
use crate::deploy::transport::batch::RENAME_RETRIES;
use crate::deploy::transport::projects::{
GitEntry, ProjectHost, ProjectRunWriter, ReleaseRecordWriter, TagRecordWriter,
check_pre_image_write, git_entry_location, project_run_names_problem_on,
tag_record_names_problem_on,
};
use crate::deploy::transport::remote::{RemoteCommand, SftpOp};
use crate::deploy::transport::winps::{WinScript, windows_component_problem};
use crate::error::CoreError;
pub(super) const GIT_PROBE_SH: &str = concat!(
"LC_ALL=C; export LC_ALL; ",
"t() { if o=$(stat -c %F -- \"$1\" 2>&1); then ",
"case \"$o\" in directory) r=dir;; \"regular file\"|\"regular empty file\") r=file;; ",
"\"symbolic link\") r=symlink;; *) r=other;; esac; ",
"else case \"$o\" in *\": No such file or directory\") r=none;; ",
"*) printf \"%s\\n\" \"$o\" >&2; exit 3;; esac; fi; }; ",
"t \"$1\"; a=$r; b=skip; c=skip; ",
"if [ \"$a\" = dir ]; then t \"$2\"; b=$r; ",
"if [ \"$b\" = dir ]; then t \"$3\"; c=$r; fi; fi; ",
"printf 'IGN-RESULT:{\"projects\":\"%s\",\"project\":\"%s\",\"git\":\"%s\"}\\n' \"$a\" \"$b\" \"$c\""
);
const GIT_PROBE_NAME: &str = "ign-git-probe";
const NOT_FOUND_CLASS: &str = "(not found or unreadable)";
impl SftpTransport {
fn project_name_problem(&self, project: &str) -> Option<String> {
if let Some(why) = component_problem(project) {
return Some(format!("the project name {why}"));
}
if self.windows
&& let Some(why) = windows_component_problem(project)
{
return Some(format!("the project name: {why}"));
}
None
}
async fn git_entry_posix(&self, project: &str) -> Result<GitEntry, CoreError> {
let projects = self.abs(&["projects".to_string()]);
let project_dir = format!("{projects}/{project}");
let git = format!("{project_dir}/.git");
let command = RemoteCommand::posix(&[
"sh",
"-c",
GIT_PROBE_SH,
GIT_PROBE_NAME,
&projects,
&project_dir,
&git,
])
.map_err(|why| self.fail(format!("the .git probe cannot be built: {why}")))?;
let answer = self.shell.exec_posix("git_entry", &command).await?;
let word = |key: &str| answer.get(key).and_then(|v| v.as_str()).unwrap_or("");
match word("projects") {
"dir" => {}
"none" => {
return Err(
self.fail("projects does not exist; is data_dir the gateway data directory?")
);
}
"symlink" => {
return Err(self.fail("projects is a symlink; symlinks are refused"));
}
"file" | "other" => return Err(self.fail("projects is not a directory")),
other => return Err(self.unexpected("projects", other)),
}
match word("project") {
"dir" => {}
"none" => return Ok(GitEntry::Absent),
"symlink" => {
return Err(self.fail(format!(
"projects/{project} is a symlink; symlinks are refused"
)));
}
"file" | "other" => {
return Err(self.fail(format!("projects/{project} is not a directory")));
}
other => return Err(self.unexpected("project", other)),
}
Ok(match word("git") {
"none" => GitEntry::Absent,
"dir" => GitEntry::Directory,
"file" => GitEntry::File,
"symlink" => GitEntry::Symlink,
"other" => GitEntry::Other,
other => return Err(self.unexpected("git", other)),
})
}
async fn git_entry_windows(&self, project: &str) -> Result<GitEntry, CoreError> {
let projects = windows_native_path(&self.abs(&["projects".to_string()]));
let project_dir = format!("{projects}\\{project}");
let git = format!("{project_dir}\\.git");
let script = WinScript::git_entry(&projects, &project_dir, &git);
let answer = self.shell.exec_powershell(&script).await?;
windows_git_entry(&answer, project).map_err(|why| self.fail(why))
}
fn unexpected(&self, level: &str, word: &str) -> CoreError {
let shown: String = word
.chars()
.filter(|c| c.is_ascii_graphic())
.take(20)
.collect();
self.fail(format!(
"the .git probe answered {shown:?} for {level}, which it does not define"
))
}
}
fn windows_git_entry(answer: &serde_json::Value, project: &str) -> Result<GitEntry, String> {
let location = git_entry_location(project);
let bad = |why: String| format!("{location}: the .git probe result is unusable ({why})");
if answer.get("ok").and_then(|v| v.as_i64()) != Some(1) {
return Err(bad("ok is not 1".to_string()));
}
let word = |key: &str| answer.get(key).and_then(|v| v.as_str());
match word("projects") {
Some("dir") => {}
Some("none") => {
return Err(format!(
"{location}: projects does not exist; is data_dir the gateway data directory?"
));
}
Some("symlink") => {
return Err(format!(
"{location}: projects is a junction or symlink; symlinks are refused"
));
}
Some("file") => return Err(format!("{location}: projects is not a directory")),
other => return Err(bad(format!("projects is {other:?}"))),
}
match word("project") {
Some("dir") => {}
Some("none") => return Ok(GitEntry::Absent),
Some("symlink") => {
return Err(format!(
"{location}: projects/{project} is a junction or symlink; symlinks are refused"
));
}
Some("file") => {
return Err(format!("{location}: projects/{project} is not a directory"));
}
other => return Err(bad(format!("project is {other:?}"))),
}
let flag = |key: &str| match answer.get(key).and_then(|v| v.as_i64()) {
Some(0) => Ok(false),
Some(1) => Ok(true),
other => Err(bad(format!("{key} is {other:?}"))),
};
match word("kind") {
Some("none") => Ok(GitEntry::Absent),
Some(kind @ ("dir" | "file")) => {
let _hidden = flag("hidden")?;
if flag("reparse")? {
Ok(GitEntry::Symlink)
} else if kind == "dir" {
Ok(GitEntry::Directory)
} else {
Ok(GitEntry::File)
}
}
other => Err(bad(format!("kind is {other:?}"))),
}
}
#[async_trait::async_trait]
impl ProjectHost for SftpTransport {
async fn git_entry(&self, project: &str) -> Result<GitEntry, CoreError> {
if let Some(why) = self.project_name_problem(project) {
return Err(self.fail(why));
}
if self.windows {
return self.git_entry_windows(project).await;
}
self.git_entry_posix(project).await
}
async fn read_release_record(
&self,
gateway: &str,
project: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let location = release_record_location(LedgerRoot::AccountHome, gateway, project);
self.read_home_file(
&release_record_rel(gateway, project),
RELEASE_RECORD_MAX_BYTES,
"the release record",
[("gateway name", gateway), ("project name", project)],
&|problem| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
},
)
.await
}
async fn read_tag_record(
&self,
gateway: &str,
provider: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let location = tag_record_location(LedgerRoot::AccountHome, gateway, provider);
let bad = |problem: String| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
};
if let Some(why) = tag_record_names_problem_on(gateway, provider, Some(self.windows)) {
return Err(bad(why));
}
self.read_home_file(
&tag_record_rel(gateway, provider),
TAG_RECORD_MAX_BYTES,
"the tag record",
[("gateway name", gateway), ("provider name", provider)],
&bad,
)
.await
}
async fn read_journal(
&self,
gateway: &str,
project: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let location = project_journal_location(LedgerRoot::AccountHome, gateway, project);
let bad = |problem: String| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
};
if let Some(why) = project_run_names_problem_on(gateway, project, None, Some(self.windows))
{
return Err(bad(why));
}
self.read_home_file(
&project_journal_rel(gateway, project),
PROJECT_JOURNAL_MAX_BYTES,
"the journal",
[("gateway name", gateway), ("project name", project)],
&bad,
)
.await
}
async fn read_pre_image(
&self,
gateway: &str,
project: &str,
name: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let location = pre_image_location(LedgerRoot::AccountHome, gateway, project, name);
let bad = |problem: String| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
};
if let Some(why) =
project_run_names_problem_on(gateway, project, Some(name), Some(self.windows))
{
return Err(bad(why));
}
self.read_home_file(
&pre_image_rel(gateway, project, name),
PRE_IMAGE_MAX_BYTES,
"the pre-image file",
[("gateway name", gateway), ("project name", project)],
&bad,
)
.await
}
}
struct HomeRecord<'a> {
rel: &'a str,
file: &'a str,
temp_name: &'a str,
what: &'a str,
replace_op: &'a str,
location: &'a str,
}
impl SftpTransport {
async fn record_walk(
&self,
home: &str,
parts: &[&str],
temp: &str,
location: &str,
what: &str,
) -> Result<Option<usize>, CoreError> {
let refuse = |why: &str| self.mirror_fail("write", format!("{location}: {why}"));
let mut budget = Budget::lenient();
let mut current = home.to_string();
for (index, name) in parts.iter().enumerate() {
let last = index + 1 == parts.len();
let mut listed = self
.list_level_as("write", std::slice::from_ref(¤t), &mut budget)
.await
.map_err(|e| named(e, location))?;
let entries = listed.pop().unwrap_or_default();
if last && entries.iter().any(|e| e.name == temp) {
return Err(refuse(
"a file of the temporary name is already there; it is not touched",
));
}
let Some(entry) = entries.iter().find(|e| e.name == *name) else {
return Ok(Some(index));
};
match (entry.kind, last) {
(EntryKind::Symlink, _) => {
return Err(refuse(
"a path component is a symlink; symlinks are refused",
));
}
(EntryKind::Dir, false) | (EntryKind::File, true) => {}
(_, false) => return Err(refuse("a path component is not a directory")),
(EntryKind::Dir, true) => return Err(refuse(&format!("{what} is a directory"))),
(_, true) => {
return Err(refuse(&format!("{what} is not a regular file")));
}
}
current = format!("{current}/{name}");
}
Ok(None)
}
async fn put_record_temp(
&self,
bytes: &[u8],
temp: &str,
location: &str,
) -> Result<(), CoreError> {
let dir = self.download_dir("write").map_err(|e| named(e, location))?;
let local = dir.path().join("record");
let written = {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options
.open(&local)
.and_then(|mut file| file.write_all(bytes).and_then(|()| file.flush()))
};
written.map_err(|e| {
self.mirror_fail(
"write",
format!(
"{location}: the record cannot be staged locally ({:?})",
e.kind()
),
)
})?;
self.run_batch(
"write",
vec![SftpOp::Put {
local,
remote: temp.to_string(),
}],
)
.await
.map_err(|e| named(e, location))
}
async fn remove_temp_after(
&self,
err: CoreError,
temp: &str,
temp_name: &str,
absent_is_fine: bool,
) -> CoreError {
let cleanup = self
.run_batch(
"write",
vec![SftpOp::Rm {
path: temp.to_string(),
}],
)
.await;
let leftover = match &cleanup {
Ok(()) => false,
Err(CoreError::TransportFailed { detail, .. }) => {
!(absent_is_fine && detail.contains(NOT_FOUND_CLASS))
}
Err(_) => true,
};
match err {
CoreError::TransportFailed {
operation,
node,
detail,
} if leftover => CoreError::TransportFailed {
operation,
node,
detail: format!("{detail}; the temporary file {temp_name} may still be there"),
},
err => err,
}
}
async fn replace_record(
&self,
temp: &str,
final_path: &str,
location: &str,
op: &str,
) -> Result<(), CoreError> {
if self.windows {
let script = WinScript::record_replace(
&windows_native_path(temp),
&windows_native_path(final_path),
RENAME_RETRIES,
);
let answer = self
.shell
.exec_powershell(&script)
.await
.map_err(|e| named(e, location))?;
return if answer.get("ok").and_then(serde_json::Value::as_i64) == Some(1) {
Ok(())
} else {
Err(self.mirror_fail(
"write",
format!("{location}: the record replace script did not report ok"),
))
};
}
let command = RemoteCommand::posix(&["mv", "-f", "--", temp, final_path])
.map_err(|why| self.mirror_fail("write", format!("{location}: {why}")))?;
self.shell
.exec_posix_status(op, &command)
.await
.map_err(|e| named(e, location))
}
}
fn named(err: CoreError, location: &str) -> CoreError {
match err {
CoreError::TransportFailed {
operation,
node,
detail,
} => CoreError::TransportFailed {
operation,
node,
detail: format!("{location}: {detail}"),
},
other => other,
}
}
#[async_trait::async_trait]
impl ReleaseRecordWriter for SftpTransport {
async fn write_release_record(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError> {
let location = release_record_location(LedgerRoot::AccountHome, 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}")));
}
if self.windows
&& let Some(why) = windows_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.write_home_record(
&HomeRecord {
rel: &release_record_rel(gateway, project),
file: RELEASE_RECORD_FILE,
temp_name: &temp_name,
what: "the release record",
replace_op: "release_record_replace",
location: &location,
},
bytes,
)
.await
}
}
#[async_trait::async_trait]
impl TagRecordWriter for SftpTransport {
async fn write_tag_record(
&self,
gateway: &str,
provider: &str,
bytes: &[u8],
) -> Result<(), CoreError> {
let location = tag_record_location(LedgerRoot::AccountHome, gateway, provider);
if let Some(why) = tag_record_names_problem_on(gateway, provider, Some(self.windows)) {
return Err(CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {why}"),
});
}
let record = TagRecord::parse(bytes, &location, gateway, provider)?;
let temp_name = format!("{TAG_RECORD_FILE}.tmp-{}", record.run_id);
self.write_home_record(
&HomeRecord {
rel: &tag_record_rel(gateway, provider),
file: TAG_RECORD_FILE,
temp_name: &temp_name,
what: "the tag record",
replace_op: "tag_record_replace",
location: &location,
},
bytes,
)
.await
}
}
#[async_trait::async_trait]
impl ProjectRunWriter for SftpTransport {
async fn write_journal(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError> {
let location = project_journal_location(LedgerRoot::AccountHome, gateway, project);
if let Some(why) = project_run_names_problem_on(gateway, project, None, Some(self.windows))
{
return Err(CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {why}"),
});
}
let journal = ProjectJournal::parse(bytes, &location, gateway, project)?;
let temp_name = format!("{PROJECT_JOURNAL_FILE}.tmp-{}", journal.run_id);
self.write_home_record(
&HomeRecord {
rel: &project_journal_rel(gateway, project),
file: PROJECT_JOURNAL_FILE,
temp_name: &temp_name,
what: "the journal",
replace_op: "project_journal_replace",
location: &location,
},
bytes,
)
.await
}
async fn clear_journal(&self, gateway: &str, project: &str) -> Result<(), CoreError> {
let location = project_journal_location(LedgerRoot::AccountHome, gateway, project);
if let Some(why) = project_run_names_problem_on(gateway, project, None, Some(self.windows))
{
return Err(CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {why}"),
});
}
let home = self.account_home().await?;
let rel = project_journal_rel(gateway, project);
let parts: Vec<&str> = rel.split('/').collect();
if self
.record_walk(&home, &parts, "", &location, "the journal")
.await?
.is_some()
{
return Ok(());
}
match self
.run_batch(
"write",
vec![SftpOp::Rm {
path: format!("{home}/{rel}"),
}],
)
.await
{
Ok(()) => Ok(()),
Err(CoreError::TransportFailed { detail, .. }) if detail.contains(NOT_FOUND_CLASS) => {
Ok(())
}
Err(err) => Err(named(err, &location)),
}
}
async fn write_pre_image(
&self,
gateway: &str,
project: &str,
run_id: &str,
name: &str,
bytes: &[u8],
) -> Result<(), CoreError> {
let location = pre_image_location(LedgerRoot::AccountHome, gateway, project, name);
check_pre_image_write(
&location,
gateway,
project,
run_id,
name,
bytes,
Some(self.windows),
)?;
let temp_name = format!("{name}.tmp-{run_id}");
self.write_home_record(
&HomeRecord {
rel: &pre_image_rel(gateway, project, name),
file: name,
temp_name: &temp_name,
what: "the pre-image file",
replace_op: "pre_image_replace",
location: &location,
},
bytes,
)
.await
}
}
impl SftpTransport {
async fn write_home_record(
&self,
target: &HomeRecord<'_>,
bytes: &[u8],
) -> Result<(), CoreError> {
let (temp_name, location) = (target.temp_name, target.location);
let home = self.account_home().await?;
let parts: Vec<&str> = target.rel.split('/').collect();
let (_, dirs) = parts.split_last().expect("the record path has components");
let missing = self
.record_walk(&home, &parts, temp_name, location, target.what)
.await?;
let dir = format!("{home}/{}", dirs.join("/"));
let temp = format!("{dir}/{temp_name}");
let final_path = format!("{dir}/{}", target.file);
if let Some(first) = missing {
let mut path = home.clone();
let mut ops = Vec::new();
for (index, name) in dirs.iter().enumerate() {
path = format!("{path}/{name}");
if index >= first {
ops.push(SftpOp::Mkdir { path: path.clone() });
}
}
self.run_batch("write", ops)
.await
.map_err(|e| named(e, location))?;
}
if let Err(err) = self.put_record_temp(bytes, &temp, location).await {
return Err(self.remove_temp_after(err, &temp, temp_name, true).await);
}
if let Err(err) = self
.replace_record(&temp, &final_path, location, target.replace_op)
.await
{
return Err(self.remove_temp_after(err, &temp, temp_name, false).await);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deploy::transport::process::{ProcessOutput, ProcessRunner};
use crate::deploy::transport::remote::RemoteShell;
use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
struct Scripted {
outs: Mutex<Vec<ProcessOutput>>,
argv: Mutex<Vec<Vec<String>>>,
}
#[async_trait::async_trait]
impl ProcessRunner for Scripted {
async fn run(
&self,
_program: &OsStr,
args: &[OsString],
_stdin: Option<Vec<u8>>,
_timeout: Duration,
) -> ProcessOutput {
self.argv.lock().expect("argv").push(
args.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect(),
);
self.outs.lock().expect("outs").remove(0)
}
}
fn ok(stdout: &str) -> ProcessOutput {
ProcessOutput {
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
code: 0,
timed_out: false,
}
}
fn answer(projects: &str, project: &str, git: &str) -> ProcessOutput {
ok(&format!(
"IGN-RESULT:{{\"projects\":\"{projects}\",\"project\":\"{project}\",\"git\":\"{git}\"}}\n"
))
}
fn host(outs: Vec<ProcessOutput>, windows: bool) -> (Arc<Scripted>, SftpTransport) {
let runner = Arc::new(Scripted {
outs: Mutex::new(outs),
argv: Mutex::new(Vec::new()),
});
let shell = RemoteShell::local_sftp_server(
Path::new("/unused"),
runner.clone(),
Duration::from_secs(3),
);
let transport = if windows {
SftpTransport::new_windows(shell, "/C:/gw/data".to_string(), "nodes.master")
} else {
SftpTransport::new(shell, "/gw/data".to_string(), "nodes.master")
};
(runner, transport)
}
fn detail(err: CoreError) -> String {
match err {
CoreError::TransportFailed { detail, .. } => detail,
other => panic!("expected transport_failed, got {other:?}"),
}
}
#[tokio::test]
async fn sftp_git_entry_posix() {
let cases = [
("dir", GitEntry::Directory),
("file", GitEntry::File),
("symlink", GitEntry::Symlink),
("other", GitEntry::Other),
("none", GitEntry::Absent),
];
for (word, want) in cases {
let (runner, t) = host(vec![answer("dir", "dir", word)], false);
assert_eq!(t.git_entry("CapApp").await.expect(word), want, "{word}");
let argv = runner.argv.lock().expect("argv");
assert_eq!(argv.len(), 1);
let command = argv[0].last().expect("remote command");
assert!(command.starts_with("'sh' '-c' '"), "{command}");
for path in [
"'/gw/data/projects'",
"'/gw/data/projects/CapApp'",
"'/gw/data/projects/CapApp/.git'",
] {
assert!(command.contains(path), "{path} in {command}");
}
assert!(!command.contains("sftp"), "{command}");
}
let (_, t) = host(vec![answer("dir", "none", "skip")], false);
assert_eq!(
t.git_entry("CapApp").await.expect("absent"),
GitEntry::Absent
);
}
#[tokio::test]
async fn sftp_git_entry_posix_failures_are_transport_failed_never_absent() {
let (_, t) = host(
vec![ProcessOutput {
stdout: Vec::new(),
stderr: b"stat: cannot statx '/gw/data/projects/CapApp/.git': Permission denied\n"
.to_vec(),
code: 3,
timed_out: false,
}],
false,
);
let why = detail(t.git_entry("CapApp").await.expect_err("denied"));
assert!(why.contains("Permission denied"), "{why}");
assert!(why.contains("git_entry"), "{why}");
let failures = [
ProcessOutput {
stdout: Vec::new(),
stderr: b"ssh: connect to host x port 22: Connection refused\n".to_vec(),
code: 255,
timed_out: false,
},
ProcessOutput {
stdout: Vec::new(),
stderr: Vec::new(),
code: -1,
timed_out: true,
},
ok("no result here\n"),
ok("IGN-RESULT:not json\n"),
];
for out in failures {
let (_, t) = host(vec![out], false);
let err = t.git_entry("CapApp").await.expect_err("failure");
assert!(matches!(err, CoreError::TransportFailed { .. }), "{err:?}");
}
for (word, needle) in [
("none", "does not exist"),
("symlink", "symlink"),
("file", "not a directory"),
] {
let (_, t) = host(vec![answer(word, "skip", "skip")], false);
let why = detail(t.git_entry("CapApp").await.expect_err(word));
assert!(why.contains(needle), "{word}: {why}");
}
for (word, needle) in [("symlink", "symlink"), ("file", "not a directory")] {
let (_, t) = host(vec![answer("dir", word, "skip")], false);
let why = detail(t.git_entry("CapApp").await.expect_err(word));
assert!(why.contains(needle), "{word}: {why}");
}
for out in [
answer("dir", "dir", "weird"),
answer("dir", "dir", "skip"),
answer("dir", "maybe", "skip"),
answer("", "dir", "dir"),
] {
let (_, t) = host(vec![out], false);
let err = t.git_entry("CapApp").await.expect_err("unknown word");
assert!(matches!(err, CoreError::TransportFailed { .. }), "{err:?}");
}
}
#[tokio::test]
async fn sftp_git_entry_refuses_unsafe_names_before_any_call() {
for name in ["", ".", "..", "../x", "a/b", "a\nb", "a\u{7}b"] {
let (runner, t) = host(Vec::new(), false);
let err = t.git_entry(name).await.expect_err(name);
assert!(matches!(err, CoreError::TransportFailed { .. }), "{err:?}");
assert!(runner.argv.lock().expect("argv").is_empty(), "{name:?}");
}
let (runner, t) = host(vec![answer("dir", "none", "skip")], false);
let name = "it's$(x)";
t.git_entry(name).await.expect("absent");
let argv = runner.argv.lock().expect("argv");
let command = argv[0].last().expect("command");
assert!(
command.contains("'/gw/data/projects/it'\\''s$(x)'"),
"{command}"
);
}
#[tokio::test]
async fn sftp_git_entry_windows_name_rules_run_before_the_probe() {
for name in ["CON", "a:b", "trail.", "a<b"] {
let (runner, t) = host(Vec::new(), true);
let err = t.git_entry(name).await.expect_err(name);
assert!(matches!(err, CoreError::TransportFailed { .. }), "{err:?}");
assert!(runner.argv.lock().expect("argv").is_empty(), "{name:?}");
}
}
fn win_answer(projects: &str, project: &str, kind: &str, hidden: i64, reparse: i64) -> String {
format!(
"IGN-RESULT:{{\"ok\":1,\"projects\":\"{projects}\",\"project\":\"{project}\",\"kind\":\"{kind}\",\"hidden\":{hidden},\"reparse\":{reparse}}}\n"
)
}
#[tokio::test]
async fn win_git_entry_parse() {
let cases = [
("dir", 1, 0, GitEntry::Directory),
("dir", 0, 0, GitEntry::Directory),
("dir", 1, 1, GitEntry::Symlink),
("dir", 0, 1, GitEntry::Symlink),
("file", 0, 0, GitEntry::File),
("file", 1, 0, GitEntry::File),
("file", 0, 1, GitEntry::Symlink),
("none", 0, 0, GitEntry::Absent),
];
for (kind, hidden, reparse, want) in cases {
let (runner, t) = host(
vec![ok(&win_answer("dir", "dir", kind, hidden, reparse))],
true,
);
assert_eq!(
t.git_entry("CapApp").await.expect(kind),
want,
"{kind} hidden={hidden} reparse={reparse}"
);
assert_eq!(runner.argv.lock().expect("argv").len(), 1, "one call");
}
let (_, t) = host(vec![ok(&win_answer("dir", "none", "skip", 0, 0))], true);
assert_eq!(
t.git_entry("CapApp").await.expect("absent"),
GitEntry::Absent
);
}
#[tokio::test]
async fn win_git_entry_failures_are_transport_failed_never_absent() {
let failures = [
ProcessOutput {
stdout: Vec::new(),
stderr: b"Get-Item : Access to the path 'C:\\x\\.git' is denied.\n".to_vec(),
code: 1,
timed_out: false,
},
ProcessOutput {
stdout: Vec::new(),
stderr: b"ssh: connect to host x port 22: Connection refused\n".to_vec(),
code: 255,
timed_out: false,
},
ProcessOutput {
stdout: Vec::new(),
stderr: Vec::new(),
code: -1,
timed_out: true,
},
ok("no result here\n"),
ok("IGN-RESULT:not json\n"),
ok("IGN-RESULT:{\"ok\":0}\n"),
ok("IGN-RESULT:{}\n"),
ProcessOutput {
stdout: win_answer("dir", "dir", "none", 0, 0).into_bytes(),
stderr: b"#< CLIXML\n<Objs><S S=\"Error\">denied</S></Objs>".to_vec(),
code: 0,
timed_out: false,
},
ok(&win_answer("dir", "dir", "weird", 0, 0)),
ok(&win_answer("dir", "dir", "skip", 0, 0)),
ok(&win_answer("dir", "maybe", "skip", 0, 0)),
ok(&win_answer("", "dir", "dir", 0, 0)),
ok(&win_answer("dir", "dir", "dir", 2, 0)),
ok(&win_answer("dir", "dir", "dir", 0, 7)),
ok("IGN-RESULT:{\"ok\":1,\"projects\":\"dir\",\"project\":\"dir\",\"kind\":\"dir\"}\n"),
];
for out in failures {
let (_, t) = host(vec![out], true);
let err = t.git_entry("CapApp").await.expect_err("failure");
assert!(matches!(err, CoreError::TransportFailed { .. }), "{err:?}");
}
for (projects, project, needle) in [
("none", "skip", "does not exist"),
("symlink", "skip", "junction or symlink"),
("file", "skip", "not a directory"),
("dir", "symlink", "junction or symlink"),
("dir", "file", "not a directory"),
] {
let (_, t) = host(vec![ok(&win_answer(projects, project, "skip", 0, 0))], true);
let why = detail(t.git_entry("CapApp").await.expect_err(needle));
assert!(why.contains(needle), "{projects}/{project}: {why}");
}
}
#[tokio::test]
async fn win_git_entry_script_is_fixed_text() {
use crate::deploy::transport::winps::{COMMAND_BUDGET, GIT_ENTRY, encoded_command};
assert!(!GIT_ENTRY.chars().any(|c| c == '\n'), "one line");
assert!(GIT_ENTRY.contains("Get-Item -LiteralPath $x -Force"));
for forbidden in [
"Test-Path",
"Exists(",
"-Recurse",
"Remove-",
"Set-",
"New-",
] {
assert!(!GIT_ENTRY.contains(forbidden), "{forbidden} in GIT_ENTRY");
}
assert!(GIT_ENTRY.contains("'ObjectNotFound'){return"));
assert!(GIT_ENTRY.contains(";throw}"));
let odd = "it's$(x)`;y";
let script = WinScript::git_entry(
"C:\\gw\\data\\projects",
&format!("C:\\gw\\data\\projects\\{odd}"),
&format!("C:\\gw\\data\\projects\\{odd}\\.git"),
);
assert_eq!(script.body, GIT_ENTRY);
let command = encoded_command(&script, COMMAND_BUDGET).expect("fits the budget");
assert!(command.as_str().chars().count() <= COMMAND_BUDGET);
assert!(!command.as_str().contains("it's"));
let long = format!(
"C:\\Program Files\\Inductive Automation\\Ignition\\data\\projects\\{}",
"P".repeat(100)
);
let script = WinScript::git_entry(
"C:\\Program Files\\Inductive Automation\\Ignition\\data\\projects",
&long,
&format!("{long}\\.git"),
);
assert!(encoded_command(&script, COMMAND_BUDGET).is_ok());
let (runner, t) = host(vec![ok(&win_answer("dir", "none", "skip", 0, 0))], true);
t.git_entry("CapApp").await.expect("absent");
let argv = runner.argv.lock().expect("argv");
let text = argv[0].join(" ");
assert!(text.contains("-EncodedCommand"), "{text}");
assert!(
!text.contains("CapApp"),
"name is inside the encoding: {text}"
);
}
#[test]
fn git_probe_script_is_one_fixed_line() {
assert!(!GIT_PROBE_SH.chars().any(char::is_control), "one line");
assert!(GIT_PROBE_SH.contains("stat -c %F --"));
assert!(
!GIT_PROBE_SH.contains("stat -L"),
"a symlink is reported, never followed"
);
assert!(
!GIT_PROBE_SH.contains("test -e"),
"C-H: test -e says absent"
);
assert!(GIT_PROBE_SH.contains("LC_ALL=C"));
}
#[cfg(unix)]
mod script {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
const SHIM: &str = "#!/bin/sh\n\
p=$4\n\
case \"$p\" in */denied/*) echo \"stat: cannot statx '$p': Permission denied\" >&2; exit 1;; esac\n\
if [ -L \"$p\" ]; then echo 'symbolic link'\n\
elif [ -d \"$p\" ]; then echo directory\n\
elif [ -f \"$p\" ] && [ ! -s \"$p\" ]; then echo 'regular empty file'\n\
elif [ -f \"$p\" ]; then echo 'regular file'\n\
else case \"$p\" in */special*/.git) echo socket;; *) echo \"stat: cannot statx '$p': No such file or directory\" >&2; exit 1;; esac\n\
fi\n";
struct Run {
code: i32,
stdout: String,
stderr: String,
}
fn run(root: &Path, project: &str) -> Run {
let bin = root.join("bin");
std::fs::create_dir_all(&bin).expect("bin");
let shim = bin.join("stat");
std::fs::write(&shim, SHIM).expect("shim");
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let projects = root.join("projects");
let dir = projects.join(project);
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new("sh")
.args(["-c", GIT_PROBE_SH, GIT_PROBE_NAME])
.arg(&projects)
.arg(&dir)
.arg(dir.join(".git"))
.env("PATH", path)
.output()
.expect("sh");
Run {
code: out.status.code().expect("exit code"),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
fn words(run: &Run) -> serde_json::Value {
assert_eq!(run.code, 0, "{}", run.stderr);
let line = run.stdout.trim_end();
serde_json::from_str(line.strip_prefix("IGN-RESULT:").expect("sentinel")).expect("json")
}
#[test]
fn probe_script_classifies_every_shape() {
let tmp = tempfile::tempdir().expect("tmp");
let root = tmp.path();
std::fs::create_dir_all(root.join("projects")).expect("projects");
assert_eq!(
words(&run(root, "CapApp")),
serde_json::json!({"projects":"dir","project":"none","git":"skip"})
);
std::fs::create_dir_all(root.join("projects/CapApp")).expect("app");
assert_eq!(
words(&run(root, "CapApp")),
serde_json::json!({"projects":"dir","project":"dir","git":"none"})
);
let git = root.join("projects/CapApp/.git");
std::fs::create_dir(&git).expect("git dir");
assert_eq!(words(&run(root, "CapApp"))["git"], "dir");
std::fs::remove_dir(&git).expect("rm");
std::fs::write(&git, "gitdir: x").expect("git file");
assert_eq!(words(&run(root, "CapApp"))["git"], "file");
std::fs::write(&git, "").expect("empty");
assert_eq!(words(&run(root, "CapApp"))["git"], "file");
std::fs::remove_file(&git).expect("rm");
std::os::unix::fs::symlink("nowhere", &git).expect("link");
assert_eq!(words(&run(root, "CapApp"))["git"], "symlink");
std::fs::write(root.join("projects/AFile"), "x").expect("file");
assert_eq!(words(&run(root, "AFile"))["project"], "file");
std::os::unix::fs::symlink("CapApp", root.join("projects/ALink")).expect("link");
let w = words(&run(root, "ALink"));
assert_eq!(w["project"], "symlink");
assert_eq!(w["git"], "skip");
std::fs::create_dir_all(root.join("projects/special1")).expect("dir");
assert_eq!(words(&run(root, "special1"))["git"], "other");
}
#[test]
fn probe_script_denied_is_a_failure_never_none() {
let tmp = tempfile::tempdir().expect("tmp");
let root = tmp.path();
std::fs::create_dir_all(root.join("projects/denied")).expect("dir");
let r = run(root, "denied");
assert_eq!(r.code, 3, "{}", r.stderr);
assert!(r.stderr.contains("Permission denied"), "{}", r.stderr);
assert!(!r.stdout.contains("IGN-RESULT"), "{}", r.stdout);
}
#[test]
fn probe_script_missing_projects_directory_is_none_at_level_one() {
let tmp = tempfile::tempdir().expect("tmp");
assert_eq!(
words(&run(tmp.path(), "CapApp")),
serde_json::json!({"projects":"none","project":"skip","git":"skip"})
);
}
}
}