Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ command = "ssh"
args = ["-F", ".local/ssh/config"]
```

Lifecycle symlink targets are single-owner. Do not run concurrent `runseal`
invocations that manage the same `target` with `cleanup = true`; one process can
replace or remove the link while another still expects to own it. Use distinct
targets when commands need to run in parallel under the same profile.

`resource://path/to/file` is a profile-only path literal. A profile that uses
resource URIs must declare:

Expand Down
23 changes: 21 additions & 2 deletions app/src/bin/runseal.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use std::path::PathBuf;
use std::process;

use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use clap::{CommandFactory, Parser};
use runseal::core::app::AppState;
use runseal::core::config::{CliInput, RawEnv, RuntimeConfig};
use runseal::core::internal_help;
use runseal::run;

#[derive(Debug, Parser)]
Expand Down Expand Up @@ -41,12 +42,16 @@ struct Cli {
}

fn main() -> Result<()> {
let cli = Cli::parse();
let mut cli = Cli::parse();
cli.command = normalize_command(cli.command);
if cli.command.is_empty() {
Cli::command().print_help()?;
println!();
return Ok(());
}
if print_internal_help(&cli.command)? {
return Ok(());
}

let config = build_runtime_config(cli)?;
let app = AppState::new(config);
Expand All @@ -69,6 +74,20 @@ fn build_runtime_config(cli: Cli) -> Result<RuntimeConfig> {
)
}

fn print_internal_help(command: &[String]) -> Result<bool> {
let Some(name) = command[0].strip_prefix('@') else {
return Ok(false);
};
if name.is_empty() {
bail!("internal command name must not be empty");
}
let Some(help) = internal_help::resolve(name, &command[1..])? else {
return Ok(false);
};
print!("{help}");
Ok(true)
}

fn normalize_command(mut command: Vec<String>) -> Vec<String> {
if command.len() > 1 && command.get(1).map(String::as_str) == Some("--") {
command.remove(1);
Expand Down
21 changes: 19 additions & 2 deletions app/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,12 @@ pub struct RuntimeConfig {

impl RuntimeConfig {
pub fn from_input(cli: CliInput, env: RawEnv, cwd: &Path) -> Result<Self> {
let runseal_home = resolve_runseal_home(&env)?;
let runseal_home = absolute_path(&resolve_runseal_home(&env)?, cwd, "RUNSEAL_HOME")?;
let profile_home = env
.runseal_profile_home
.filter(|path| non_empty_path(path))
.unwrap_or_else(|| runseal_home.join("profiles"));
let profile_home = absolute_path(&profile_home, cwd, "RUNSEAL_PROFILE_HOME")?;
let profile_path = resolve_profile_path(cli.profile, cwd, &profile_home)?;

Ok(Self {
Expand Down Expand Up @@ -105,7 +106,12 @@ fn resolve_profile_path(
.map(|path| format!("- {}", path.display()))
.collect::<Vec<_>>()
.join("\n");
bail!("profile file not found. searched:\n{searched}")
bail!(
"no runseal profile found from {} upward and no default profile under {}.\nHint: create runseal.toml here, pass --profile <path>, or add {}/default.toml.\nSearched:\n{searched}",
cwd.display(),
profile_home.display(),
profile_home.display()
)
}

fn discovery_candidates(cwd: &Path, profile_home: &Path) -> Vec<PathBuf> {
Expand All @@ -131,6 +137,17 @@ fn absolute_file(path: &Path) -> Result<PathBuf> {
.map(|path| path.to_path_buf())
}

fn absolute_path(path: &Path, cwd: &Path, name: &str) -> Result<PathBuf> {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
path.absolutize()
.with_context(|| format!("failed to absolutize {name}: {}", path.display()))
.map(|path| path.to_path_buf())
}

pub fn profile_extensions() -> &'static [&'static str] {
&["toml", "yaml", "yml", "json"]
}
27 changes: 20 additions & 7 deletions app/src/core/injections/symlink.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::path::Path;

use anyhow::{Result, bail};
use anyhow::{Context, Result, bail};

use crate::core::profile::{SymlinkOnExist, SymlinkProfile};

Expand Down Expand Up @@ -62,37 +62,50 @@ impl SymlinkInjection {
if meta.file_type().is_dir() {
bail!("refusing to replace directory target: {}", target.display());
}
std::fs::remove_file(target)?;
std::fs::remove_file(target)
.with_context(|| shared_target_context("replace", target))?;
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}

if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create symlink parent: {}", parent.display())
})?;
}
create_symlink(source, target)?;
create_symlink(source, target).with_context(|| shared_target_context("create", target))?;
Ok(())
}

fn shutdown_at(&self, target: &Path, source: &Path) -> Result<()> {
let metadata = std::fs::symlink_metadata(target)?;
let metadata = std::fs::symlink_metadata(target)
.with_context(|| shared_target_context("inspect during shutdown", target))?;
if !metadata.file_type().is_symlink() {
bail!("refusing to remove non-symlink at {}", target.display());
}
let link_target = std::fs::read_link(target)?;
let link_target = std::fs::read_link(target)
.with_context(|| shared_target_context("read during shutdown", target))?;
if link_target != source {
bail!(
"refusing to remove symlink with unexpected target: {}",
target.display()
);
}
std::fs::remove_file(target)?;
std::fs::remove_file(target)
.with_context(|| shared_target_context("remove during shutdown", target))?;
Ok(())
}
}

fn shared_target_context(action: &str, target: &Path) -> String {
format!(
"failed to {action} symlink target {}; lifecycle symlink targets are single-owner and may already be managed by another concurrent runseal process",
target.display()
)
}

#[cfg(unix)]
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(source, target)
Expand Down
2 changes: 1 addition & 1 deletion app/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ pub mod app;
pub mod config;
pub(crate) mod env_key;
pub mod injections;
mod internal_help;
pub mod internal_help;
pub mod profile;
pub mod runtime;
42 changes: 42 additions & 0 deletions app/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,48 @@ fn symlink_lifecycle() {
assert!(!target.exists(), "symlink should be cleaned after command");
}

#[cfg(unix)]
#[test]
fn symlink_shutdown_contention() {
let temp = TempDir::new().expect("temp dir should be created");
let source = temp.path().join("source.txt");
let target = temp.path().join("links/source.txt");
let profile = temp.path().join("profile.json");
std::fs::write(&source, "sealed").expect("source should be written");
std::fs::write(
&profile,
format!(
r#"{{
"injections": [
{{
"type": "symlink",
"source": "{}",
"target": "{}",
"cleanup": true
}}
]
}}"#,
source.display(),
target.display()
),
)
.expect("profile should be written");

let output = bin()
.env("RUNSEAL_HOME", temp.path().join("home"))
.arg("--profile")
.arg(profile.to_str().expect("path should be UTF-8"))
.args(shell_args(&format!("rm -- '{}'", target.display())))
.output()
.expect("runseal should run");

assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
assert!(stderr.contains("symlink shutdown failed"));
assert!(stderr.contains("lifecycle symlink targets are single-owner"));
assert!(stderr.contains("another concurrent runseal process"));
}

#[cfg(unix)]
#[test]
fn argv_injection_prefixes_command() {
Expand Down
107 changes: 107 additions & 0 deletions app/tests/first_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use std::{collections::BTreeMap, path::Path, process::Command};

use tempfile::TempDir;

fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_runseal"))
}

fn output_map(stdout: &str) -> BTreeMap<String, String> {
stdout
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect()
}

#[test]
fn internal_help_without_profile() {
let temp = TempDir::new().expect("temp dir should be created");
let cwd = temp.path().join("empty");
std::fs::create_dir_all(&cwd).expect("empty cwd should be created");

for (args, expected) in [
(vec!["@profile", "--help"], "Usage: runseal @profile"),
(vec!["@resources", "--help"], "Usage: runseal @resources"),
(vec!["@resolve", "--help"], "Usage: runseal @resolve"),
(vec!["@wrappers", "--help"], "Usage: runseal @wrappers"),
(vec!["@which", "--help"], "Usage: runseal @which :<wrapper>"),
] {
let output = bin()
.current_dir(&cwd)
.env("RUNSEAL_HOME", temp.path().join("home"))
.args(args.clone())
.output()
.expect("runseal should run");

assert!(output.status.success(), "{args:?} should succeed");
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert!(
stdout.contains(expected),
"expected stdout for {args:?} to contain {expected:?}, got {stdout:?}"
);
}
}

#[test]
fn missing_profile_hint() {
let temp = TempDir::new().expect("temp dir should be created");
let cwd = temp.path().join("empty");
std::fs::create_dir_all(&cwd).expect("empty cwd should be created");

let output = bin()
.current_dir(&cwd)
.env("RUNSEAL_HOME", temp.path().join("home"))
.arg("@profile")
.output()
.expect("runseal should run");

assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
assert!(stderr.contains("no runseal profile found from"));
assert!(stderr.contains("Hint: create runseal.toml here"));
}

#[test]
fn profile_paths_are_absolute() {
let temp = TempDir::new().expect("temp dir should be created");
let project = temp.path().join("project");
std::fs::create_dir_all(&project).expect("project should be created");
std::fs::write(
project.join("runseal.toml"),
"injections = []\n[resources]\nroot = \".resource\"\n",
)
.expect("profile should be written");

let output = bin()
.current_dir(&project)
.env("RUNSEAL_HOME", "../home")
.arg("@profile")
.output()
.expect("runseal should run");

assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
let values = output_map(&stdout);
for key in [
"RUNSEAL_HOME",
"RUNSEAL_PROFILE_HOME",
"RUNSEAL_PROFILE_PATH",
] {
let value = values.get(key).expect("profile output should include key");
assert!(Path::new(value).is_absolute(), "{key} should be absolute");
}

let wrapper_path = values
.get("RUNSEAL_WRAPPER_PATH")
.expect("profile output should include wrapper path");
for entry in std::env::split_paths(wrapper_path) {
assert!(
entry.is_absolute(),
"wrapper path entry should be absolute: {}",
entry.display()
);
}
}
Loading