use crate::context::{Context, Environment};
use crate::error::Result;
use crate::service::Permit;
use crate::store::RawStore;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) mod administered;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(any(test, feature = "test-support"))]
pub(crate) mod memory;
pub(crate) mod program;
pub(crate) mod token;
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
#[cfg(target_os = "linux")]
use linux as os;
#[cfg(target_os = "macos")]
use macos as os;
#[cfg(windows)]
use windows as os;
pub(crate) use administered::Administered;
pub(crate) use os::{fs, proc, user};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
MacOs,
Linux,
Windows,
}
pub const OS: Os = os::OS;
pub(crate) const SECURITY: &str = "/usr/bin/security";
impl Os {
pub(crate) fn secrets_tool(self) -> Option<&'static str> {
match self {
Os::MacOs => Some(SECURITY),
Os::Linux | Os::Windows => None,
}
}
pub(crate) fn make_private_command(self, kind: Kind, paths: &[&str]) -> Option<String> {
match self {
Os::MacOs | Os::Linux => {
let mode = match kind {
Kind::Directory => "700",
Kind::File => "600",
Kind::Any => "go-rwx",
};
Some(format!("chmod {mode} {}", paths.join(" ")))
}
Os::Windows => None,
}
}
pub(crate) fn guarded_folders(self) -> &'static [&'static str] {
match self {
Os::MacOs => &[
"Desktop",
"Documents",
"Downloads",
"Library/Mobile Documents",
"Library/CloudStorage",
],
Os::Linux | Os::Windows => &[],
}
}
pub(crate) fn package_bins(self) -> &'static [&'static str] {
match self {
Os::MacOs => &["/opt/homebrew/bin", "/usr/local/bin"],
Os::Linux | Os::Windows => &[],
}
}
pub(crate) fn same_path(self, a: &Path, b: &Path) -> bool {
match self {
Os::MacOs | Os::Linux => a == b,
Os::Windows => same_path_in_any_case(a, b),
}
}
pub(crate) fn app_command_line(self, app: &Path) -> Option<PathBuf> {
match self {
Os::MacOs => (app.extension() == Some("app".as_ref()))
.then(|| app.join("Contents/Helpers/pitboard")),
Os::Linux | Os::Windows => None,
}
}
}
pub fn same_path_in_any_case(a: &Path, b: &Path) -> bool {
let (mut a, mut b) = (a.components(), b.components());
loop {
match (a.next(), b.next()) {
(None, None) => return true,
(Some(one), Some(other))
if same_name_in_any_case(one.as_os_str(), other.as_os_str()) => {}
_ => return false,
}
}
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn lies_in(path: &Path, place: &Path) -> bool {
let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let (real, real_place) = (resolved(&path, LINKS), resolved(place, LINKS));
near(&path, place)
|| near(&real, &real_place)
|| meets(&real, &real_place)
|| meets(&real_place, &real)
}
#[cfg(any(test, feature = "test-support"))]
const LINKS: u8 = 32;
#[cfg(any(test, feature = "test-support"))]
fn near(a: &Path, b: &Path) -> bool {
begins(a, b) || begins(b, a)
}
#[cfg(any(test, feature = "test-support"))]
fn begins(path: &Path, with: &Path) -> bool {
let with = plain(with);
plain(path)
.ancestors()
.any(|above| same_path_in_any_case(above, &with))
}
#[cfg(any(test, feature = "test-support"))]
fn plain(path: &Path) -> PathBuf {
use std::path::{Component, Prefix};
let mut parts = path.components();
let Some(Component::Prefix(prefix)) = parts.next() else {
return path.to_path_buf();
};
let head = match prefix.kind() {
Prefix::VerbatimDisk(disk) => format!("{}:", char::from(disk)),
Prefix::VerbatimUNC(server, share) => format!(
r"\\{}\{}",
server.to_string_lossy(),
share.to_string_lossy()
),
_ => return path.to_path_buf(),
};
PathBuf::from(head).join(parts.as_path())
}
#[cfg(any(test, feature = "test-support"))]
fn meets(a: &Path, b: &Path) -> bool {
let Some((deepest, below_b)) = b
.ancestors()
.find_map(|above| Some((fs::file_id(above)?, b.strip_prefix(above).ok()?)))
else {
return false;
};
a.ancestors().any(|above| {
fs::file_id(above) == Some(deepest)
&& a.strip_prefix(above)
.is_ok_and(|below_a| near(below_a, below_b))
})
}
#[cfg(any(test, feature = "test-support"))]
fn resolved(path: &Path, links: u8) -> PathBuf {
for found in path.ancestors() {
let Ok(rest) = path.strip_prefix(found) else {
continue;
};
if let Ok(real) = std::fs::canonicalize(found) {
return beyond(real, rest);
}
let link = std::fs::symlink_metadata(found).is_ok_and(|it| it.file_type().is_symlink());
if let (true, Some(left), Ok(to)) = (link, links.checked_sub(1), std::fs::read_link(found))
{
let to = match found.parent() {
Some(parent) => parent.join(to),
None => to,
};
return resolved(&beyond(to, rest), left);
}
}
path.to_path_buf()
}
#[cfg(any(test, feature = "test-support"))]
fn beyond(mut at: PathBuf, rest: &Path) -> PathBuf {
for part in rest.components() {
match part {
std::path::Component::ParentDir => {
at.pop();
}
std::path::Component::CurDir => {}
part => at.push(part),
}
}
at
}
fn same_name_in_any_case(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool {
fn upper(c: char) -> char {
if u32::from(c) > 0xFFFF {
return c;
}
let mut upper = c.to_uppercase();
match (upper.next(), upper.next()) {
(Some(one), None) => one,
_ => c,
}
}
let (a, b) = (a.to_string_lossy(), b.to_string_lossy());
a.chars().map(upper).eq(b.chars().map(upper))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(
windows,
allow(
dead_code,
reason = "only a login shell says or is late, and Windows has none; W17 says which \
`PATH` an app finds programs on there"
)
)]
pub(crate) enum LoginPath {
Said(String),
Unknown,
Late,
}
pub(crate) fn login_path(env: &Environment) -> LoginPath {
os::login_path(env)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Access {
pub(crate) shared: bool,
pub(crate) described: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Private {
Yes,
Shared,
NotOwn,
Link,
NotADirectory,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Kind {
Directory,
File,
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Elevation {
Normal,
Elevated { why: &'static str },
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Floor {
Met,
Below { build: u32 },
Unknown,
}
pub(crate) const WINDOWS_FLOOR: u32 = 26100;
#[cfg(any(windows, test))]
pub(crate) fn windows_floor(version: Option<(u32, u32, u32)>) -> Floor {
match version {
None => Floor::Unknown,
Some((major, minor, build)) if (major, minor, build) >= (10, 0, WINDOWS_FLOOR) => {
Floor::Met
}
Some((_, _, build)) => Floor::Below { build },
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Process {
pub(crate) pid: u32,
pub(crate) path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub pid: u32,
pub parent: u32,
pub path: PathBuf,
}
pub(crate) fn named(all: Vec<Listed>, program: &str) -> Vec<Process> {
all.into_iter()
.filter(|p| p.path.file_name().is_some_and(|name| name == program))
.map(|p| Process {
pid: p.pid,
path: p.path,
})
.collect()
}
pub struct Jar {
pub version: i64,
pub compatible: i64,
pub cookies: Vec<Cookie>,
}
pub struct Cookie {
pub host: String,
pub name: String,
pub path: String,
pub stored: Vec<u8>,
pub expires_utc: i64,
}
impl std::fmt::Debug for Cookie {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cookie")
.field("host", &self.host)
.field("name", &self.name)
.field("path", &self.path)
.field("stored", &format_args!("{} bytes", self.stored.len()))
.field("expires_utc", &self.expires_utc)
.finish()
}
}
#[derive(Debug)]
pub enum JarError {
Absent,
Unsettled(String),
Unknown(String),
Unreadable(String),
Unsupported,
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) use os::write_jar;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Ran {
pub exit: Option<i32>,
pub stdout: String,
pub timed_out: bool,
}
pub(crate) trait Host: Send + Sync + std::fmt::Debug {
fn foreign_secrets(&self, ctx: &Context, account: &str) -> Option<Box<dyn RawStore>>;
fn file(&self, path: PathBuf) -> Box<dyn RawStore>;
fn vault(&self, ctx: &Context) -> Box<dyn RawStore>;
fn vault_is_shared(&self) -> bool;
fn listing(&self) -> std::result::Result<Vec<Listed>, String>;
fn processes(&self, program: &str) -> Option<Vec<Process>> {
Some(named(self.listing().ok()?, program))
}
fn cookies(
&self,
jar: &Path,
hosts: &[&str],
names: &[&str],
) -> std::result::Result<Jar, JarError>;
fn app_version(&self, bundle: &Path) -> Option<String>;
fn app_id(&self, bundle: &Path) -> Option<String>;
fn scheduler(&self) -> Option<&dyn Scheduler>;
fn elevation(&self, ctx: &Context) -> Elevation;
fn floor(&self) -> Floor;
fn administered_file(&self, path: &Path) -> Administered {
if administered::READ_BY_REAL_HOSTS {
administered::read_text(path)
} else {
Administered::Unset
}
}
fn managed_preference(&self, domain: &str, key: &str) -> Administered;
}
pub(crate) trait Scheduler: Send + Sync + std::fmt::Debug {
fn location(&self, ctx: &Context) -> PathBuf;
fn installed(&self, ctx: &Context) -> bool;
fn program(&self, ctx: &Context) -> Option<PathBuf>;
fn environment(&self, ctx: &Context) -> Vec<(String, String)>;
fn put(
&self,
ctx: &Context,
permit: Permit,
program: &Path,
environment: &[(String, String)],
) -> Result<()>;
fn remove(&self, ctx: &Context, permit: Permit) -> Result<bool>;
fn started_this_process(&self, said: Option<&str>) -> bool;
}
pub(crate) fn default_pitboard_home(home: &Path) -> PathBuf {
match OS {
Os::MacOs | Os::Linux => home.join(".pitboard"),
Os::Windows => PathBuf::new(),
}
}
pub(crate) fn current() -> Arc<dyn Host> {
os::host()
}
pub(crate) fn current_program() -> std::io::Result<PathBuf> {
os::current_program()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_store_of_secrets_is_offered_only_where_there_is_one() {
let ctx = Context::for_unit_test();
let offered = ctx.host().foreign_secrets(&ctx, "someone");
assert_eq!(
offered.map(|k| k.kind()),
match OS {
Os::MacOs => Some(crate::store::Backend::Keychain),
Os::Linux => None,
Os::Windows => None,
}
);
assert_eq!(
ctx.host().file(PathBuf::from("/nowhere/at/all")).kind(),
crate::store::Backend::File
);
}
#[test]
fn paths_are_compared_exactly_on_macos_and_linux() {
for os in [Os::MacOs, Os::Linux] {
let same = |a: &str, b: &str| os.same_path(Path::new(a), Path::new(b));
assert!(same("ChatGPT.app", "ChatGPT.app"), "{os:?}");
assert!(
same("/Applications/ChatGPT.app/", "/Applications//ChatGPT.app"),
"{os:?}"
);
assert!(!same("chatgpt.app", "ChatGPT.app"), "{os:?}");
assert!(!same("ChatGPT.app", "ChatGPT"), "{os:?}");
}
}
#[test]
fn paths_are_compared_in_any_case_on_windows() {
let same = |a: &str, b: &str| Os::Windows.same_path(Path::new(a), Path::new(b));
assert!(same("Codex.exe", "codex.EXE"));
assert!(same("/Programs/OpenAI/Codex/", "/programs//openai/CODEX"));
assert!(same(r"C:\Users\Dana\Codex.exe", r"c:\users\dana\codex.exe"));
assert!(same("Éclair", "éCLAIR"));
assert!(!same("e\u{301}clair", "\u{e9}clair"), "not normalised");
assert!(!same("Straße", "STRASSE"), "ß is upper case as it is");
assert!(!same("Codex.exe", "Codex"));
assert!(!same("/a/b", "/a/b/c"));
}
#[test]
fn a_path_lies_in_a_place_in_any_case_and_past_a_folder_not_made_yet() {
let root = std::env::temp_dir().join(format!("pitboard-lies-in-{}", std::process::id()));
let place = root.join("Library/Application Support");
let lies = |path: &str| lies_in(&root.join(path), &place);
assert!(lies("Library/Application Support/x"));
assert!(lies("library/APPLICATION SUPPORT/x"), "in any case");
assert!(lies("LIBRARY"), "it holds it");
assert!(lies("never-made/../Library/Application Support/x"));
assert!(!lies("Library/Application Supportx"));
assert!(!lies("never-made/Library/Application Support"));
assert!(!lies("Library/../elsewhere/Application Support"));
assert!(!root.exists());
}
#[test]
#[cfg(windows)]
fn a_verbatim_path_lies_where_its_plain_spelling_does() {
let lies = |path: &str, place: &str| lies_in(Path::new(path), Path::new(place));
assert!(lies(
r"\\?\C:\Users\Dana\Library\x",
r"c:\users\dana\library"
));
assert!(lies(
r"C:\Users\Dana\Library\x",
r"\\?\c:\users\dana\library"
));
assert!(!lies(
r"\\?\D:\Users\Dana\Library\x",
r"C:\Users\Dana\Library"
));
}
#[test]
fn a_scheduler_is_offered_where_pitboard_writes_for_one() {
assert_eq!(
current().scheduler().is_some(),
match OS {
Os::MacOs | Os::Linux => true,
Os::Windows => false,
}
);
}
#[test]
fn the_host_reads_sudo_from_the_context_and_root_from_the_process() {
let under = |value: &str| {
let env: Environment = [("HOME", "/Users/x"), ("SUDO_UID", value)]
.into_iter()
.collect();
let ctx = Context::for_command_line(&env);
ctx.host().elevation(&ctx)
};
assert_eq!(
under("501"),
match OS {
Os::MacOs | Os::Linux => Elevation::Elevated { why: "with sudo" },
Os::Windows => user::elevation(false),
}
);
let ctx = Context::for_unit_test();
assert_eq!(
ctx.host().elevation(&ctx),
user::elevation(false),
"withheld from a unit test, so only root is read"
);
assert_eq!(under(""), user::elevation(false));
}
#[test]
fn the_floor_is_windows_11_24h2_with_server_2025_counted() {
for met in [(10, 0, 26100), (10, 0, 26200), (10, 0, 27000), (11, 0, 100)] {
assert_eq!(windows_floor(Some(met)), Floor::Met, "{met:?}");
}
for (version, build) in [
((10, 0, 26099), 26099),
((10, 0, 22631), 22631),
((10, 0, 19045), 19045),
((6, 3, 9600), 9600),
] {
assert_eq!(
windows_floor(Some(version)),
Floor::Below { build },
"{version:?}"
);
}
assert_eq!(windows_floor(None), Floor::Unknown);
assert_eq!(WINDOWS_FLOOR, 26100);
}
#[test]
fn the_floor_is_met_on_the_systems_the_tests_run_on() {
assert_eq!(current().floor(), Floor::Met);
}
}