Skip to content
Closed
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
6 changes: 2 additions & 4 deletions crates/basal-core/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,7 @@ pub fn validate_values(manifest: &Manifest, values: &Value, now: i64) -> Result<
value.as_str().and_then(resolved_path).is_some_and(|real| {
let inside = under
.iter()
.filter_map(|root| basal_host::builtins::expand_home(root))
.filter_map(|root| std::fs::canonicalize(root).ok())
.filter_map(|root| basal_host::builtins::canonical_root(root))
.any(|root| real.starts_with(root));
if let Some(text) = real.to_str() {
canonical = Value::String(text.into());
Expand Down Expand Up @@ -463,8 +462,7 @@ pub fn grant_guards(
let root = values[&name].as_str().expect("validated path").to_owned();
let under = under
.iter()
.filter_map(|r| basal_host::builtins::expand_home(r))
.filter_map(|r| std::fs::canonicalize(r).ok())
.filter_map(|r| basal_host::builtins::canonical_root(r))
.filter_map(|r| r.to_str().map(str::to_owned))
.collect();
Some(basal_host::builtins::TemplateRoot {
Expand Down
31 changes: 21 additions & 10 deletions crates/basal-core/tests/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ fn base() -> Value {
fn parse(value: &Value) -> Result<Manifest, ManifestError> {
Manifest::parse(&value.to_string())
}
/// An absolute root as a manifest on this platform spells it: on Windows,
/// roots are drive paths.
#[cfg(unix)]
const TMP: &str = "/tmp";
#[cfg(windows)]
const TMP: &str = r"C:\tmp";
/// `TMP` joined with one more component, spelled for this platform.
fn under_tmp(name: &str) -> String {
format!("{TMP}{}{name}", std::path::MAIN_SEPARATOR)
}
fn with_parameter(decl: Value) -> Value {
let mut m = base();
m["params"] = json!({"value":decl});
Expand Down Expand Up @@ -55,7 +65,7 @@ fn all_parameter_declarations_require_closed_valid_bounds() {
json!({"type":"enum","values":[]}),
json!({"type":"enum","values":(0..33).map(|n|format!("v{n}")).collect::<Vec<_>>()}),
json!({"type":"enum","values":["v","v"]}),
json!({"type":"path","under":["/tmp","/tmp"]}),
json!({"type":"path","under":[TMP,TMP]}),
json!({"type":"url","hosts":["example.com","example.com"]}),
json!({"type":"timestamp","max_ahead":"forever"}),
json!({"type":"unknown"}),
Expand Down Expand Up @@ -139,7 +149,7 @@ fn template_limits_and_lifetime_are_bounded_and_required() {

#[test]
fn placeholders_must_occupy_whole_strings_in_typed_slots_only() {
let mut m = with_parameter(json!({"type":"path","under":["/tmp"]}));
let mut m = with_parameter(json!({"type":"path","under":[TMP]}));
for field in ["purpose", "placement"] {
m[field] = json!("$params.value");
assert!(
Expand Down Expand Up @@ -167,11 +177,11 @@ fn placeholders_must_occupy_whole_strings_in_typed_slots_only() {
json!({"fs":{"read":["$params.value"]}}),
),
(
json!({"type":"path","under":["/tmp"]}),
json!({"type":"path","under":[TMP]}),
json!({"git":{"read":["$params.value"]}}),
),
(
json!({"type":"repo","under":["/tmp"]}),
json!({"type":"repo","under":[TMP]}),
json!({"fs":{"write":["$params.value"]}}),
),
(
Expand Down Expand Up @@ -288,19 +298,20 @@ fn parameter_failures_are_reported_in_name_order_with_bounds() {
#[test]
fn effective_manifest_narrows_every_allowed_slot_and_preserves_static_grants() {
let mut m = base();
m["params"] = json!({"path":{"type":"path","under":["/tmp"]},"repo":{"type":"repo","under":["/tmp"]},"url":{"type":"url","hosts":["example.com"]},"interval":{"type":"duration","min":"1m","max":"1h"}});
m["fs"] = json!({"read":["$params.path","/tmp"],"write":["$params.path"]});
m["params"] = json!({"path":{"type":"path","under":[TMP]},"repo":{"type":"repo","under":[TMP]},"url":{"type":"url","hosts":["example.com"]},"interval":{"type":"duration","min":"1m","max":"1h"}});
m["fs"] = json!({"read":["$params.path",TMP],"write":["$params.path"]});
m["git"] = json!({"read":["$params.repo"]});
m["net"] = json!({"fetch":[{"host":"$params.url"}]});
m["trigger"]["schedule"]["interval"] = json!("$params.interval");
let mut m = parse(&m).unwrap();
let (file, repo) = (under_tmp("file"), under_tmp("repo"));
effective(
&mut m,
&json!({"path":"/tmp/file","repo":"/tmp/repo","url":"https://example.com/only","interval":"2m"}),
&json!({"path":file,"repo":repo,"url":"https://example.com/only","interval":"2m"}),
);
assert_eq!(m.fs.as_ref().unwrap().read, vec!["/tmp/file", "/tmp"]);
assert_eq!(m.fs.unwrap().write, vec!["/tmp/file"]);
assert_eq!(m.git.unwrap().read, vec!["/tmp/repo"]);
assert_eq!(m.fs.as_ref().unwrap().read, vec![file.as_str(), TMP]);
assert_eq!(m.fs.unwrap().write, vec![file]);
assert_eq!(m.git.unwrap().read, vec![repo]);
assert_eq!(m.net.unwrap().fetch[0].host, "example.com");
assert_eq!(m.trigger.schedule.unwrap().interval.as_deref(), Some("2m"));
}
Expand Down
2 changes: 1 addition & 1 deletion crates/basal-host/src/builtins/fs/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1007,7 +1007,7 @@ fn process_profile_directory() -> Result<String, Denial> {
}

#[cfg(windows)]
fn expand_root(root: &str) -> Result<std::borrow::Cow<'_, str>, Denial> {
pub(crate) fn expand_root(root: &str) -> Result<std::borrow::Cow<'_, str>, Denial> {
let suffix = if root == "~" {
""
} else if let Some(suffix) = root.strip_prefix("~/") {
Expand Down
47 changes: 46 additions & 1 deletion crates/basal-host/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,19 @@ impl Grant {

/// Resolve symlinks component by component, including the deepest existing
/// ancestor of a future path. A dangling symlink is not a future directory.
/// The result is spelled as the `fs` and `git` built-ins accept a root
/// ([`plain_spelling`]).
pub fn resolved_path(text: &str) -> Option<PathBuf> {
use std::path::Component;
let path = expand_home(text)?;
let mut resolved = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => continue,
// A Windows drive (`C:`) on its own names that drive's current
// directory, so it is resolved together with the root that
// follows it in an absolute path.
Component::Prefix(prefix) => resolved.push(prefix.as_os_str()),
Component::ParentDir => {
resolved.pop();
}
Expand All @@ -233,7 +239,35 @@ pub fn resolved_path(text: &str) -> Option<PathBuf> {
}
}
}
resolved.is_absolute().then_some(resolved)
if !resolved.is_absolute() {
return None;
}
plain_spelling(resolved)
}

/// An existing root with `~` expanded and every symlink resolved, spelled as
/// the `fs` and `git` built-ins accept a root ([`plain_spelling`]). `None`
/// when it is not absolute or cannot be resolved.
pub fn canonical_root(text: &str) -> Option<PathBuf> {
plain_spelling(std::fs::canonicalize(expand_home(text)?).ok()?)
}

/// A resolved path as the built-ins spell it. On Windows,
/// `std::fs::canonicalize` returns an extended-length `\\?\X:\...` path,
/// which the Windows `fs` and `git` built-ins refuse; this drops that
/// prefix and accepts only a drive path whose spelling the Windows `fs`
/// built-in accepts. Elsewhere the path is returned unchanged.
#[cfg(not(windows))]
fn plain_spelling(path: PathBuf) -> Option<PathBuf> {
Some(path)
}

#[cfg(windows)]
fn plain_spelling(path: PathBuf) -> Option<PathBuf> {
let text = path.to_str()?;
let plain = text.strip_prefix(r"\\?\").unwrap_or(text);
fs::windows::validate_raw_spelling(plain).ok()?;
Some(PathBuf::from(plain))
}

/// Whether `kind` is a file, git or network built-in.
Expand Down Expand Up @@ -286,7 +320,18 @@ pub fn envelope(args: &Value, grant: &Grant) -> Value {

/// Expands a leading `~` to `$HOME`. Returns `None` for a path that is not
/// absolute after expansion, or when `$HOME` is unset or relative.
///
/// On Windows `~` is the profile folder of the process token, the folder
/// the Windows `fs` built-in expands a `~` root to, never an environment
/// variable; `None` when that profile is unavailable or the expanded path
/// is not a drive path the `fs` built-in accepts.
pub fn expand_home(text: &str) -> Option<PathBuf> {
#[cfg(windows)]
if text == "~" || text.starts_with("~/") {
return fs::windows::expand_root(text)
.ok()
.map(|path| PathBuf::from(path.into_owned()));
}
let home = || {
std::env::var_os("HOME")
.map(PathBuf::from)
Expand Down
13 changes: 13 additions & 0 deletions crates/basal-module/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ pub fn scratch(tag: &str) -> PathBuf {
dir
}

/// The real path of an existing `path`, spelled as a manifest root. On
/// Windows `std::fs::canonicalize` adds the extended-length `\\?\` prefix,
/// which manifest roots and the `fs` and `git` built-ins refuse, so it is
/// removed here.
pub fn canonical(path: &std::path::Path) -> PathBuf {
let real = std::fs::canonicalize(path).expect("canonical path");
#[cfg(windows)]
if let Some(plain) = real.to_str().and_then(|text| text.strip_prefix(r"\\?\")) {
return PathBuf::from(plain);
}
real
}

pub struct Options {
pub hosts: Option<Hosts>,
pub worker_binary: Option<PathBuf>,
Expand Down
2 changes: 1 addition & 1 deletion crates/basal-module/tests/flow_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ impl WorkDirectory {
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(std::fs::canonicalize(path).unwrap())
Self(common::canonical(&path))
}
}
impl Drop for WorkDirectory {
Expand Down
6 changes: 3 additions & 3 deletions crates/basal-module/tests/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,7 @@ fn activation_uses_effective_grants_and_journaled_self_and_params() {
let target = f.dir.join("target");
std::fs::write(&target, "watched").unwrap();
std::fs::write(f.dir.join("neighbor"), "private").unwrap();
let root = std::fs::canonicalize(&f.dir).unwrap();
let root = common::canonical(&f.dir);
let mut m = manifest();
m["params"]["path"] = json!({"type":"path","under":[root]});
m["fs"] = json!({"read":["$params.path"]});
Expand Down Expand Up @@ -1303,7 +1303,7 @@ fn template_dry_run_uses_canonical_values_effective_grants_and_capture() {
let target = f.dir.join("target");
std::fs::write(&target, "watched").unwrap();
std::fs::write(f.dir.join("neighbor"), "private").unwrap();
let root = std::fs::canonicalize(&f.dir).unwrap();
let root = common::canonical(&f.dir);
let mut m = manifest();
m["params"]["path"] = json!({"type":"path","under":[root]});
m["fs"] = json!({"read":["$params.path"]});
Expand Down Expand Up @@ -1569,7 +1569,7 @@ fn instance_dry_run_uses_bound_values_owner_grants_and_approved_version() {
let f = setup("instance-dry-bound", true);
let target = f.dir.join("target");
std::fs::write(&target, "bound").unwrap();
let root = std::fs::canonicalize(&f.dir).unwrap();
let root = common::canonical(&f.dir);
let mut m = manifest();
m["params"]["path"] = json!({"type":"path","under":[root]});
m["fs"] = json!({"read":["$params.path"]});
Expand Down
17 changes: 9 additions & 8 deletions mutations.d/core-events-templates.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ runner = "cargo"
package = "basal-core"
platforms = ["linux"]
target = "--lib"
expect_red = ["events::tests::body_is_journaled_before_script_and_replay_never_calls_live"]
expect_red = ["events::tests::body_is_journaled_before_script_and_replay_never_calls_live", "events::tests::journaled_flow_targeted_body_is_checked_against_run"]
only = true
build_timeout_s = 3600
timeout_s = 600
Expand Down Expand Up @@ -293,7 +293,7 @@ runner = "cargo"
package = "basal-core"
platforms = ["linux"]
target = "--test templates"
expect_red = ["placeholders_must_occupy_whole_strings_in_typed_slots_only"]
expect_red = ["placeholders_must_occupy_whole_strings_in_typed_slots_only", "one_shot_requires_timestamp_placeholder_and_once_policy"]
only = true
file = "crates/basal-core/src/templates.rs"
old = "if !fits {\n return Err(placeholder(field));"
Expand Down Expand Up @@ -321,7 +321,7 @@ runner = "cargo"
package = "basal-core"
platforms = ["linux"]
target = "--test templates"
expect_red = ["scalar_values_keep_spelling_and_enforce_types_and_bounds"]
expect_red = ["scalar_values_keep_spelling_and_enforce_types_and_bounds", "integer_bounds_and_values_remain_exact_script_numbers", "parameter_failures_are_reported_in_name_order_with_bounds", "timestamps_normalize_utc_milliseconds_and_reject_finer_precision"]
only = true
file = "crates/basal-core/src/templates.rs"
old = "if !fits {\n return Err(parameter_refusal(\"param_out_of_bounds\", name, bound));"
Expand Down Expand Up @@ -363,11 +363,11 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["path_and_repo_values_resolve_symlinks_and_future_ancestors_before_bounds"]
expect_red = ["path_and_repo_values_resolve_symlinks_and_future_ancestors_before_bounds", "template_dry_run_path_and_repo_canonicalization_and_bounds_match_instances"]
only = true
file = "crates/basal-core/src/templates.rs"
old = "inside && (!matches!(decl, Parameter::Repo { .. }) || is_repository(&real))"
new = "true /* NON-VACUITY BREAK */"
new = "(inside || true) /* NON-VACUITY BREAK */ && (!matches!(decl, Parameter::Repo { .. }) || is_repository(&real))"

[[control]]
id = "template-expiry-admission"
Expand Down Expand Up @@ -451,7 +451,7 @@ expect_red = ["idempotent_creation_moves_version_extends_never_shortens_and_keep
only = true
file = "crates/basal-core/src/templates.rs"
old = "unwrap_or(expires_at).max(expires_at)"
new = "unwrap_or(expires_at).min(expires_at) /* NON-VACUITY BREAK */"
new = "unwrap_or(expires_at).max(expires_at).min(expires_at) /* NON-VACUITY BREAK */"

[[control]]
id = "template-one-shot-ends"
Expand All @@ -461,7 +461,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["one_shot_fires_once_after_downtime_and_ends_on_success_failure_or_cancel"]
expect_red = ["one_shot_fires_once_after_downtime_and_ends_on_success_failure_or_cancel", "a_template_run_ends_only_itself_after_finishing_even_when_it_fails"]
only = true
file = "crates/basal-core/src/templates.rs"
old = "end(tx, &flow, at)?;"
Expand Down Expand Up @@ -657,8 +657,9 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["template_dry_run_bounds_preserve_integer_and_timestamp_rules"]
expect_red = ["template_dry_run_bounds_preserve_integer_and_timestamp_rules", "template_dry_run_path_and_repo_canonicalization_and_bounds_match_instances"]
only = true
select = { filter = "template_dry_run_" }
file = "crates/basal-core/src/templates.rs"
old = """ };
if !fits {"""
Expand Down
4 changes: 2 additions & 2 deletions mutations.d/core-runtime.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1261,7 +1261,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test event_preamble"
expect_red = ["worker_preamble_supplies_verified_json_and_synthetic_capture_calls_nothing_live"]
expect_red = ["worker_preamble_supplies_verified_json_and_synthetic_capture_calls_nothing_live", "delivered_body_flow_mismatch_fails_run_before_script"]
only = true
build_timeout_s = 3600
timeout_s = 600
Expand Down Expand Up @@ -1421,7 +1421,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["activation_uses_effective_grants_and_journaled_self_and_params"]
expect_red = ["activation_uses_effective_grants_and_journaled_self_and_params", "instance_dry_run_uses_bound_values_owner_grants_and_approved_version", "one_shot_fires_once_after_downtime_and_ends_on_success_failure_or_cancel", "template_dry_run_at_fires_once_immediately_with_canonical_timestamp", "template_dry_run_creates_no_rows_and_uses_no_quota", "template_dry_run_path_and_repo_canonicalization_and_bounds_match_instances", "template_dry_run_uses_canonical_values_effective_grants_and_capture", "template_stat_of_a_missing_target_reports_absence_then_appearance"]
only = true
file = "crates/basal-core/src/driver.rs"
old = "params_input: self.run.params_input.clone(),"
Expand Down
2 changes: 1 addition & 1 deletion mutations.d/host-builtins.toml
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["template_stat_of_a_missing_target_reports_absence_then_appearance"]
expect_red = ["template_stat_of_a_missing_target_reports_absence_then_appearance", "missing_stat_with_an_escaped_ancestor_is_refused_not_reported_absent"]
only = true
file = "crates/basal-host/src/builtins/mod.rs"
old = 'return Some(json!({"exists":false}));'
Expand Down
4 changes: 2 additions & 2 deletions mutations.d/host-routing.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1389,7 +1389,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["macos"]
target = "--test hosts"
expect_red = ["digest_status_tag_is_top_level_and_absence_preserves_wire_bytes"]
expect_red = ["digest_status_tag_is_top_level_and_absence_preserves_wire_bytes", "journaled_sink_intent_is_byte_identical_after_a_cut"]
only = true
build_timeout_s = 3600
timeout_s = 600
Expand All @@ -1405,7 +1405,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["macos"]
target = "--test hosts"
expect_red = ["digest_status_tag_is_top_level_and_absence_preserves_wire_bytes"]
expect_red = ["digest_status_tag_is_top_level_and_absence_preserves_wire_bytes", "core_requests_match_c3_c4_field_for_field"]
only = true
build_timeout_s = 3600
timeout_s = 600
Expand Down
6 changes: 3 additions & 3 deletions mutations.d/module.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2295,7 +2295,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["template_dry_run_uses_canonical_values_effective_grants_and_capture"]
expect_red = ["template_dry_run_uses_canonical_values_effective_grants_and_capture", "instance_dry_run_uses_bound_values_owner_grants_and_approved_version", "template_dry_run_at_fires_once_immediately_with_canonical_timestamp", "template_dry_run_creates_no_rows_and_uses_no_quota", "template_dry_run_path_and_repo_canonicalization_and_bounds_match_instances"]
only = true
file = "crates/basal-module/src/dryrun.rs"
old = "UPDATE runs SET self_input=?2,params_input=?3,grant_guards=?4 WHERE run_id=?1"
Expand All @@ -2309,7 +2309,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["template_dry_run_creates_no_rows_and_uses_no_quota"]
expect_red = ["template_dry_run_creates_no_rows_and_uses_no_quota", "instance_dry_run_uses_bound_values_owner_grants_and_approved_version", "template_dry_run_captures_self_end_with_values_and_bound_instance"]
only = true
file = "crates/basal-module/src/ops.rs"
old = "let guards = basal_core::templates::grant_guards(&manifest, &values);"
Expand Down Expand Up @@ -2338,7 +2338,7 @@ runner = "cargo"
package = "basal-module"
platforms = ["linux"]
target = "--test templates"
expect_red = ["instance_dry_run_uses_bound_values_owner_grants_and_approved_version"]
expect_red = ["instance_dry_run_uses_bound_values_owner_grants_and_approved_version", "template_dry_run_at_fires_once_immediately_with_canonical_timestamp"]
only = true
file = "crates/basal-module/src/ops.rs"
old = "serde_json::from_str(values).map_err(invalid_params)?,"
Expand Down
2 changes: 1 addition & 1 deletion mutations.d/proto.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ runner = "cargo"
package = "basal-proto"
platforms = ["linux"]
target = "--lib"
expect_red = ["tests::codemode_wire_codes_preserve_shell_and_round_trip_tool_json"]
expect_red = ["tests::codemode_wire_codes_preserve_shell_and_round_trip_tool_json", "tests::confinement_wire_tags_and_linux_fields_are_stable"]
only = true
file = "crates/basal-proto/src/limits.rs"
old = "pub const PROTOCOL_VERSION: u32 = 9;"
Expand Down
Loading
Loading