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
1 change: 1 addition & 0 deletions docs/superpowers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Master status index for all specs and implementation plans in this directory.
| Raw `cargo audit` advisories `cargo deny` skips | As of 2026-10-17 the scheduled `cargo audit` job still reports RUSTSEC-2026-0306 (faster-hex 0.10.0, via gix-hash/gix-packetline) and RUSTSEC-2026-0205 (scc 2.4.0, via dev-dep `serial_test`), which the blocking `cargo deny check advisories` does not flag. Triage both: bump through gix/serial_test or add dated ignores to the workflow. |
| `binary` zip extract leaves a truncated file on failure | `lib/src/atoms/binary/extract.rs:123-125` creates the destination before copying, so a checksum failure mid-copy leaves a partial binary; `plan()` then sees it exists and never retries. Same under zip 2 and zip 9 (bug-scan, 2026-10-17). Write to a temp file in the same directory and rename on success. |
| Narrow `zip` to `deflate` | zip 9 default features pull AES/pbkdf2/sha, bzip2, lzma, ppmd and zstd stacks that `extract.rs` does not need for release-binary zips. `default-features = false, features = ["deflate"]` shrinks the dependency surface and retires the `bzip2-1.0.6` licence allow, at the cost of rejecting zips with other entry methods. Kept out of the 2026-10-17 advisory PR as a behaviour change. |
| Two macOS `package` tests not hermetic | `package::remove::tests::plan_skips_package_not_installed_homebrew` (unwrap on `ENOENT`, `remove.rs:107`) and `package::repository::tests::plan_with_available_provider_adds_repository` fail on the Mac Studio under a non-login ssh shell, before and after etch-cli's chflags fix (2026-10-17). Likely depend on `brew` being on `PATH`; macOS-only, so Linux CI never runs them. |
| `flatpak.install` | Install Flatpak apps from Flathub or other remotes. Needed to manage Steam (`com.valvesoftware.Steam`) and other Flatpak apps from etch-config. Fields: `app_id:`, `remote:` (default `flathub`), idempotent via `flatpak list`. |
| Maintainability pass | Once the ai-config maintainability gate (2026-07-24 spec) ships, run a worldclass pass in this repo against its thresholds — not scheduled yet. |
| Flip `missing_docs` to `warn` | 402 undocumented public items as of 2026-08-02 (`make docs-debt` rechecks), concentrated in `etch-lib` — the only crate in either repo with a real cross-crate consumer and published metadata. Shipped at `allow` with a dated comment; this row is its expiry. Incidentally, `make docs-debt` is the only standing evidence that lint output is being produced at all — once the 45 `Debug` derives land, every `warn` lint finds zero again, so `docs-debt` is the one command in either repo whose output is non-zero. If a toolchain change silently stopped promoting `warn` to error, both `warn` lints would go green and stay green; a `docs-debt` count that drops to 0 without anyone documenting anything is the signal. |
Expand Down
57 changes: 37 additions & 20 deletions lib/src/actions/file/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ impl Action for FileFlags {
use std::os::unix::ffi::OsStrExt;
use std::path::Path;

// Read current flags to determine the full desired flag set.
// chflags replaces ALL user flags — passing only the delta would
// clobber flags not mentioned in this action.
use crate::atoms::file::bsd_flags::symbolic_delta;

// Read current flags so the plan names only the managed flags that change.
let path = Path::new(&self.path);
let cstr = CString::new(path.as_os_str().as_bytes())
.map_err(|e| anyhow!("invalid path: {e}"))?;
Expand All @@ -67,24 +67,8 @@ impl Action for FileFlags {
let current = sb.st_flags;
let desired = compute_desired(current, &self.flags)?;

if current == desired {
let Some(flags_str) = symbolic_delta(current, desired) else {
return Ok(vec![]);
}

// Convert desired bitmask back to chflags flag names.
// chflags replaces all user flags with exactly what you name,
// so we must name the full desired state, not just the delta.
let mut names: Vec<&str> = Vec::new();
if desired & crate::atoms::file::chflags::UF_HIDDEN != 0 {
names.push("hidden");
}
if desired & crate::atoms::file::chflags::UF_IMMUTABLE != 0 {
names.push("uchg");
}
let flags_str = if names.is_empty() {
"none".to_string()
} else {
names.join(",")
};

let privilege_provider = utilities::get_privilege_provider(_contexts)
Expand Down Expand Up @@ -271,4 +255,37 @@ mod tests {
|| steps[0].atom.to_string().contains("CommandExec")
);
}

#[test]
#[cfg(target_os = "macos")]
fn plan_clears_a_flag_with_no_prefix_when_privileged() {
use super::FileFlags;
use crate::actions::file::FileActionConfig;
use crate::actions::Action;

let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
std::fs::write(&path, "").unwrap();
let status = std::process::Command::new("chflags")
.arg("hidden")
.arg(&path)
.status()
.unwrap();
assert!(status.success());

let action = FileFlags {
path: path.display().to_string(),
flags: vec!["nohidden".to_string()],
config: FileActionConfig { privileged: true },
};
let steps = action
.plan(
&crate::manifests::Manifest::default(),
&crate::contexts::Contexts::default(),
)
.unwrap();
assert_eq!(1, steps.len());
let expected = format!("chflags nohidden {}", path.display());
assert!(steps[0].atom.to_string().ends_with(&expected));
}
}
121 changes: 121 additions & 0 deletions lib/src/atoms/file/bsd_flags.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! Platform-independent BSD file-flag logic, kept outside the macOS-gated `chflags`
//! module so its tests run on the Linux CI runner.

pub const UF_HIDDEN: u32 = 0x8000;
pub const UF_IMMUTABLE: u32 = 0x0002;

/// The flags `file.flags` manages, paired with their `chflags(1)` names.
const MANAGED: [(u32, &str); 2] = [(UF_HIDDEN, "hidden"), (UF_IMMUTABLE, "uchg")];

/// Builds the symbolic `chflags(1)` argument that moves `current` to `desired`.
///
/// Symbolic `chflags` is additive: a name sets its flag, a `no`-prefixed name clears
/// it, and unnamed flags are left alone. So only the managed flags that change are
/// named, and clearing is spelled `nohidden`, never `none` (which `chflags` rejects).
/// Returns `None` when no managed flag differs, so there is nothing to run.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn symbolic_delta(current: u32, desired: u32) -> Option<String> {
let words: Vec<String> = MANAGED
.iter()
.filter(|(bit, _)| (current ^ desired) & bit != 0)
.map(|(bit, name)| {
if desired & bit != 0 {
(*name).to_string()
} else {
format!("no{name}")
}
})
.collect();
(!words.is_empty()).then(|| words.join(","))
}

#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;

const UF_NODUMP: u32 = 0x0001;

/// Applies a symbolic argument the way `chflags(1)` does: each name sets its bit,
/// each `no`-prefixed name clears it, and every flag left unnamed is untouched.
fn apply(current: u32, arg: &str) -> u32 {
arg.split(',').fold(current, |acc, word| {
let (clear, name) = match word.strip_prefix("no") {
Some(rest) => (true, rest),
None => (false, word),
};
let bit = MANAGED
.iter()
.find(|(_, n)| *n == name)
.unwrap_or_else(|| panic!("chflags rejects {word:?}"))
.0;
if clear {
acc & !bit
} else {
acc | bit
}
})
}

#[test]
fn no_change_needs_no_command() {
assert_eq!(None, symbolic_delta(UF_HIDDEN, UF_HIDDEN));
assert_eq!(None, symbolic_delta(0, 0));
}

#[test]
fn setting_a_flag_names_it() {
assert_eq!(Some("hidden".to_string()), symbolic_delta(0, UF_HIDDEN));
}

#[test]
fn clearing_one_flag_names_it_with_no_prefix() {
let current = UF_HIDDEN | UF_IMMUTABLE;
assert_eq!(
Some("nohidden".to_string()),
symbolic_delta(current, UF_IMMUTABLE)
);
}

#[test]
fn clearing_every_flag_never_uses_none() {
let arg = symbolic_delta(UF_HIDDEN | UF_IMMUTABLE, 0).unwrap();
assert_eq!("nohidden,nouchg", arg);
}

#[test]
fn setting_and_clearing_together_names_both() {
assert_eq!(
Some("nohidden,uchg".to_string()),
symbolic_delta(UF_HIDDEN, UF_IMMUTABLE)
);
}

#[test]
fn unmanaged_flags_are_never_named() {
let arg = symbolic_delta(UF_NODUMP, UF_NODUMP | UF_HIDDEN).unwrap();
assert_eq!("hidden", arg);
}

proptest! {
#[test]
fn delta_applied_to_current_reaches_desired(
current in 0u32..=0xFFFF,
set_hidden in any::<Option<bool>>(),
set_uchg in any::<Option<bool>>(),
) {
let mut desired = current;
for (want, bit) in [(set_hidden, UF_HIDDEN), (set_uchg, UF_IMMUTABLE)] {
match want {
Some(true) => desired |= bit,
Some(false) => desired &= !bit,
None => {}
}
}
match symbolic_delta(current, desired) {
None => prop_assert_eq!(current, desired),
Some(arg) => prop_assert_eq!(desired, apply(current, &arg)),
}
}
}
}
3 changes: 1 addition & 2 deletions lib/src/atoms/file/chflags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ use anyhow::anyhow;
use std::ffi::CString;
use std::path::PathBuf;

pub const UF_HIDDEN: u32 = 0x8000;
pub const UF_IMMUTABLE: u32 = 0x0002;
pub use super::bsd_flags::{UF_HIDDEN, UF_IMMUTABLE};

#[derive(Debug)]
pub struct Chflags {
Expand Down
1 change: 1 addition & 0 deletions lib/src/atoms/file/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod bsd_flags;
#[cfg(target_os = "macos")]
pub mod chflags;
#[cfg(target_os = "macos")]
Expand Down
Loading