blob: d1f9cb9dbb84ae83851372936ae4b176483b9d0f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::store;
use maka_event_log::root::{
FileLease,
windows::{account_home, create_private_file, open_nofollow, publish_file, validate_private},
};
use maka_sandbox::{
filesystem::{Access, Compiled, Scope},
windows::{
acl::{Removal, Target},
ensure_drained,
},
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File},
io,
path::{Path, PathBuf},
};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Pending {
source: PathBuf,
destination: PathBuf,
directory: bool,
}
/// Shared across installations belonging to this OS account. Each consumer
/// records its actual native Job before launch: a crashed Host's closed handles
/// alone do not prove that all of its descendants have stopped.
pub(super) struct Guards {
root: PathBuf,
id: Uuid,
lease: FileLease,
pins: Vec<File>,
}
pub(super) fn recover() -> io::Result<()> {
let root = account_home()?.join(".maka-sandbox-guards");
if !root.try_exists()? {
return Ok(());
}
let _gate = store::admission(&root)?;
collect(&root)
}
impl Guards {
pub fn prepare(policy: &Compiled) -> io::Result<Self> {
if policy.policy().default != Access::Read
|| !policy.policy().deny_globs.is_empty()
|| policy
.policy()
.rules
.iter()
.any(|rule| rule.path.components().count() > 256)
{
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"Windows sandbox requires a bounded read-default path policy",
));
}
// Resolve through the account token, not USERPROFILE or a particular
// State Root. Separate Hosts must coordinate on the same namespace.
let root = account_home()?.join(".maka-sandbox-guards");
maka_event_log::root::private_directory(&root)?;
store::lease_file(&root.join("admission.lock"))?;
let _gate = store::admission(&root)?;
collect(&root)?;
let id = Uuid::new_v4();
let name = format!("lease-{id}");
store::lease_file(&root.join(format!("{name}.lock")))?;
let lease = FileLease::acquire(&root.join(format!("{name}.lock")))?;
let mut pins = Vec::new();
let mut targets = Vec::new();
let result = (|| {
for rule in policy
.policy()
.rules
.iter()
.filter(|rule| rule.access != Access::Write)
{
materialize(&root, &rule.path, rule.scope == Scope::Subtree)?;
let (target, pin) = Target::pin(&rule.path)?;
targets.push(target);
pins.push(pin);
}
store::publish(&root, &format!("{name}.json"), &targets)
})();
if let Err(error) = result {
drop(pins);
drop(lease);
return match collect(&root) {
Ok(()) => Err(error),
Err(cleanup) => Err(io::Error::other(
maka_agent_facts::tools::ToolError::CleanupUnconfirmed(cleanup.to_string()),
)),
};
}
Ok(Self {
root,
id,
lease,
pins,
})
}
/// Persist before the first native process is created.
pub fn bind(&self, execution: Uuid) -> io::Result<()> {
let _gate = store::admission(&self.root)?;
store::publish(
&self.root,
&format!("lease-{}.job.json", self.id),
&execution,
)
}
pub fn finish(self) -> io::Result<()> {
let Self {
root, lease, pins, ..
} = self;
drop(pins);
drop(lease);
let _gate = store::admission(&root)?;
collect(&root)
}
}
fn materialize(root: &Path, path: &Path, directory: bool) -> io::Result<()> {
match path.symlink_metadata() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let parent = path
.parent()
.ok_or_else(|| io::Error::other("guard has no parent"))?;
materialize(root, parent, true)?;
let (_identity, _parent) = Target::pin(parent)?;
let id = Uuid::new_v4();
let name = format!("guard-{id}");
let pending = Pending {
source: parent.join(format!(".maka-guard-{id}")),
destination: path.to_owned(),
directory,
};
// The staging name is never reused. Before publication it is private to
// this OS user; after publication recovery uses only its persistent file ID.
store::publish(root, &format!("{name}.pending.json"), &pending)?;
if directory {
maka_event_log::root::private_directory(&pending.source)?;
} else {
create_private_file(&pending.source)?.sync_all()?;
}
let (target, file) = Target::capture(&pending.source)?;
store::publish(root, &format!("{name}.json"), &target)?;
drop(file);
publish_file(&pending.source, path)
}
fn active_targets(root: &Path) -> io::Result<Vec<Target>> {
let mut active = Vec::new();
for entry in fs::read_dir(root)? {
let filename = entry?.file_name();
let Some(name) = filename
.to_str()
.and_then(|name| name.strip_suffix(".lock"))
.filter(|name| name.starts_with("lease-"))
else {
continue;
};
let lock = root.join(format!("{name}.lock"));
let lease = match FileLease::acquire(&lock) {
Ok(lease) => Some(lease),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => None,
Err(error) => return Err(error),
};
let job: Option<Uuid> = store::read(&root.join(format!("{name}.job.json")))?;
let mut running = lease.is_none();
if let Some(job) = job {
match ensure_drained(job) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => running = true,
Err(error) => return Err(error),
}
}
if running {
active.extend(store::required::<Vec<Target>>(
&root.join(format!("{name}.json")),
)?);
} else {
for suffix in ["job.json", "json"] {
store::remove(&root.join(format!("{name}.{suffix}")))?;
}
drop(lease);
// Unique execution identities are never reused; unlinking this
// inactive lease cannot split future lifecycle ownership.
store::remove(&lock)?;
}
}
Ok(active)
}
fn collect(root: &Path) -> io::Result<()> {
let active = active_targets(root)?;
let mut entries = Vec::new();
for entry in fs::read_dir(root)? {
let filename = entry?.file_name();
let Some(name) = filename
.to_str()
.and_then(|name| name.strip_suffix(".pending.json"))
.filter(|name| name.starts_with("guard-"))
else {
continue;
};
let pending: Pending = store::required(&root.join(format!("{name}.pending.json")))?;
let target = store::read::<Target>(&root.join(format!("{name}.json")))?;
entries.push((name.to_owned(), pending, target));
}
let active_paths: Vec<_> = entries
.iter()
.filter(|(_, _, target)| {
target
.as_ref()
.is_some_and(|target| active.contains(target))
})
.map(|(_, pending, _)| pending.destination.clone())
.collect();
// Children precede synthetic parents. Parents of live guards must remain
// recorded, rather than being mistaken for user-populated directories.
entries
.sort_by_key(|(_, pending, _)| std::cmp::Reverse(pending.destination.components().count()));
for (name, pending, target) in entries {
if active_paths
.iter()
.any(|path| path.starts_with(&pending.destination))
{
continue;
}
if let Some(target) = target {
match target.remove_empty()? {
Removal::InUse => continue,
Removal::Removed | Removal::Preserved => remove_record(root, &name)?,
}
} else {
// Publication cannot precede the durable identity record. The only
// possible orphan is the private staging name from this intent.
match open_nofollow(&pending.source, false) {
Ok(file) => {
validate_private(&file)?;
drop(file);
let (target, file) = Target::capture(&pending.source)?;
drop(file);
match target.remove_empty()? {
Removal::InUse => continue,
Removal::Removed | Removal::Preserved => {}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
remove_record(root, &name)?;
}
}
Ok(())
}
fn remove_record(root: &Path, name: &str) -> io::Result<()> {
// The object was removed or preserved as user data before either record
// disappears. An interrupted removal of the second record is harmless.
store::remove(&root.join(format!("{name}.json")))?;
store::remove(&root.join(format!("{name}.pending.json")))
}
#[cfg(test)]
mod tests {
use super::*;
use maka_sandbox::{
filesystem::{Policy, Rule},
windows::ExecutionJob,
};
use std::os::windows::io::AsHandle;
#[test]
fn protection_outlives_peer_settlement_and_a_lost_host_until_its_native_tree_exits() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let boundary = root.join("missing/protected");
let policy = Policy {
default: Access::Read,
rules: vec![
Rule::subtree(root, Access::Write),
Rule::subtree(&boundary, Access::Read),
],
deny_globs: vec![],
}
.compile()
.unwrap();
let first = Guards::prepare(&policy).unwrap();
let survivor = Guards::prepare(&policy).unwrap();
first.finish().unwrap();
assert!(
boundary.is_dir(),
"one consumer must not revoke its peer's protection"
);
let id = Uuid::new_v4();
let job = ExecutionJob::create(id).unwrap();
let executable = PathBuf::from(std::env::var_os("SystemRoot").unwrap())
.join(r"System32\WindowsPowerShell\v1.0\powershell.exe");
let mut child = std::process::Command::new(executable)
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
"Start-Sleep -Seconds 30",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
job.assign(child.as_handle()).unwrap();
survivor.bind(id).unwrap();
drop(survivor); // Lost Host handles do not prove the Job has drained.
let result = recover();
let retained = boundary.is_dir();
drop(job);
child.wait().unwrap();
result.unwrap();
assert!(
retained,
"recovery must observe the native execution, not only file leases"
);
recover().unwrap();
assert!(
!root.join("missing").exists(),
"last settlement must collect empty synthetic parents"
);
}
}