initial commit
Security scan / rust-and-policy (push) Has been cancelled
Security scan / container-scanners (push) Has been cancelled

This commit is contained in:
2026-04-05 19:14:26 -04:00
commit 4025dc2e12
55 changed files with 10702 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
plans/
+106
View File
@@ -0,0 +1,106 @@
# Gitea Actions: run security checks on merge requests (and pushes to default branches).
# Requires: Gitea Actions enabled, at least one runner with Docker (for container scanners).
# Your PowerShell script targets Windows/Podman; CI here uses Linux + Docker — same tools, different glue.
#
# If `uses: actions/...` fails on your instance, configure the runners action mirror
# (see https://docs.gitea.com/usage/actions/overview) or replace with full URLs from your mirror.
name: Security scan
on:
pull_request:
push:
branches: [main, master]
workflow_dispatch:
permissions:
contents: read
jobs:
rust-and-policy:
runs-on: ubuntu-latest
# Self-hosted: e.g. runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Install cargo-deny and cargo-audit
run: |
cargo install cargo-deny --locked --no-track
cargo install cargo-audit --locked --no-track
- name: cargo deny
run: cargo deny check
- name: cargo audit
run: cargo audit
container-scanners:
runs-on: ubuntu-latest
needs: rust-and-policy
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Trivy (filesystem)
run: |
docker run --rm \
-v "$PWD:/src:ro" \
aquasec/trivy:latest fs \
--skip-dirs target \
--severity HIGH,CRITICAL \
--scanners vuln \
--exit-code 0 \
/src
- name: Gitleaks
run: |
docker run --rm \
-v "$PWD:/src:ro" \
ghcr.io/gitleaks/gitleaks:latest detect \
--source /src \
--verbose
- name: OSV-Scanner
run: |
docker run --rm \
-v "$PWD:/src:ro" \
ghcr.io/google/osv-scanner:latest scan -r /src
- name: Semgrep (Rust ruleset)
run: |
docker run --rm \
-v "$PWD:/src:ro" \
semgrep/semgrep:latest semgrep \
--error \
--config p/rust \
/src
# Optional: Qryon is slow to install in CI (~minutes). Uncomment when runners are fast enough or you cache ~/.cargo.
# qryon:
# runs-on: ubuntu-latest
# needs: rust-and-policy
# steps:
# - uses: actions/checkout@v4
# - uses: dtolnay/rust-toolchain@stable
# - uses: Swatinem/rust-cache@v2
# - name: Install qryon (rma-cli)
# run: cargo install rma-cli --locked --no-track
# - name: Qryon scan
# run: |
# mkdir -p security/reports
# qryon scan . --no-color -p default -f json -o security/reports/qryon-report.json
+5
View File
@@ -0,0 +1,5 @@
/target
# Local Qryon index/cache (created by `qryon scan`)
.qryon/
/security/reports/
/security/tools/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/crates/byte_draft/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/crates/byte_draft/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/crates/byte_draft_desktop/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ByteDraft.iml" filepath="$PROJECT_DIR$/.idea/ByteDraft.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
Generated
+4908
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
[workspace]
resolver = "2"
members = [
"crates/byte_draft",
"crates/byte_draft_desktop",
]
+6
View File
@@ -0,0 +1,6 @@
# cargo-audit (optional). Transitive egui/eframe stack; revisit when upgrading eframe.
[advisories]
ignore = [
"RUSTSEC-2024-0384",
"RUSTSEC-2024-0436",
]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "byte_draft"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[dependencies]
quick-xml = "0.37"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
thiserror = "1"
toml = "0.8"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,269 @@
//! Find / replace helpers (shared by the GUI and unit tests).
use regex::RegexBuilder;
/// Expands backslash escapes in the replacement string before it is passed to the regex engine.
/// This matches common editor behavior when "regex" is enabled (e.g. `\n` → newline).
///
/// - `\n` `\r` `\t` — line break, carriage return, tab
/// - `\0` — NUL
/// - `\\` — literal `\`
/// - `\$` — becomes `$$` so the regex crate emits a literal `$` after substitution
/// - Any other `\x` — backslash and `x` are kept as two characters
pub fn expand_regex_replacement(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('0') => out.push('\0'),
Some('\\') => out.push('\\'),
Some('$') => out.push_str("$$"),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
/// All non-overlapping match spans (byte offsets) for the find widget.
pub fn collect_find_matches(
text: &str,
query: &str,
case_sensitive: bool,
use_regex: bool,
) -> Result<Vec<(usize, usize)>, String> {
if query.is_empty() {
return Ok(Vec::new());
}
if use_regex {
let mut b = RegexBuilder::new(query);
b.case_insensitive(!case_sensitive);
let re = b.build().map_err(|e| e.to_string())?;
Ok(re.find_iter(text).map(|m| (m.start(), m.end())).collect())
} else if case_sensitive {
Ok(text
.match_indices(query)
.map(|(i, m)| (i, i + m.len()))
.collect())
} else {
let mut b = RegexBuilder::new(&regex::escape(query));
b.case_insensitive(true);
let re = b.build().map_err(|e| e.to_string())?;
Ok(re.find_iter(text).map(|m| (m.start(), m.end())).collect())
}
}
/// Replace the leftmost match; returns whether anything changed.
pub fn replace_first_match(
text: &mut String,
query: &str,
with: &str,
case_sensitive: bool,
use_regex: bool,
) -> Result<bool, String> {
if query.is_empty() {
return Ok(false);
}
if use_regex {
let mut b = RegexBuilder::new(query);
b.case_insensitive(!case_sensitive);
let re = b.build().map_err(|e| e.to_string())?;
let expanded = expand_regex_replacement(with);
let new = re.replacen(text.as_str(), 1, expanded.as_str());
let changed = new.as_ref() != text.as_str();
*text = new.into_owned();
Ok(changed)
} else if case_sensitive {
if let Some(pos) = text.find(query) {
let end = pos + query.len();
text.replace_range(pos..end, with);
Ok(true)
} else {
Ok(false)
}
} else {
let mut b = RegexBuilder::new(&regex::escape(query));
b.case_insensitive(true);
let re = b.build().map_err(|e| e.to_string())?;
let new = re.replacen(text.as_str(), 1, with);
let changed = new.as_ref() != text.as_str();
*text = new.into_owned();
Ok(changed)
}
}
/// Replace all matches; returns the number of matches that were replaced.
pub fn replace_all_matches(
text: &mut String,
query: &str,
with: &str,
case_sensitive: bool,
use_regex: bool,
) -> Result<usize, String> {
if query.is_empty() {
return Ok(0);
}
let n = collect_find_matches(text, query, case_sensitive, use_regex)?.len();
if n == 0 {
return Ok(0);
}
if use_regex {
let mut b = RegexBuilder::new(query);
b.case_insensitive(!case_sensitive);
let re = b.build().map_err(|e| e.to_string())?;
let expanded = expand_regex_replacement(with);
*text = re.replace_all(text.as_str(), expanded.as_str()).into_owned();
} else if case_sensitive {
*text = text.replace(query, with);
} else {
let mut b = RegexBuilder::new(&regex::escape(query));
b.case_insensitive(true);
let re = b.build().map_err(|e| e.to_string())?;
*text = re.replace_all(text.as_str(), with).into_owned();
}
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_newline_tab_cr() {
assert_eq!(expand_regex_replacement(r"\n"), "\n");
assert_eq!(expand_regex_replacement(r"\r"), "\r");
assert_eq!(expand_regex_replacement(r"\t"), "\t");
assert_eq!(expand_regex_replacement(r"a\nb"), "a\nb");
assert_eq!(expand_regex_replacement(r"\n\n"), "\n\n");
}
#[test]
fn expand_backslash_and_dollar() {
assert_eq!(expand_regex_replacement(r"\\"), "\\");
assert_eq!(expand_regex_replacement(r"\\n"), "\\n");
assert_eq!(expand_regex_replacement(r"\\\n"), "\\\n");
assert_eq!(expand_regex_replacement(r"\$"), "$$");
assert_eq!(expand_regex_replacement(r"\$1"), "$$1");
assert_eq!(expand_regex_replacement(r"x\$y"), "x$$y");
}
#[test]
fn expand_unknown_escape_preserved() {
assert_eq!(expand_regex_replacement(r"\z"), "\\z");
assert_eq!(expand_regex_replacement("trailing\\"), "trailing\\");
}
#[test]
fn expand_capture_refs_untouched() {
assert_eq!(expand_regex_replacement("$1"), "$1");
assert_eq!(expand_regex_replacement("$&"), "$&");
assert_eq!(expand_regex_replacement(r"$1\n$2"), "$1\n$2");
}
#[test]
fn regex_replace_all_inserts_newline() {
let mut s = "a,b,c".to_string();
let n = replace_all_matches(&mut s, r",", r"\n", true, true).unwrap();
assert_eq!(n, 2);
assert_eq!(s, "a\nb\nc");
}
#[test]
fn regex_replace_inserts_tab_and_cr() {
let mut s = "x".to_string();
replace_first_match(&mut s, "x", r"\t\r", true, true).unwrap();
assert_eq!(s, "\t\r");
}
#[test]
fn regex_replace_all_no_matches_returns_zero() {
let mut s = "abc".to_string();
let n = replace_all_matches(&mut s, "z", "y", true, true).unwrap();
assert_eq!(n, 0);
assert_eq!(s, "abc");
}
#[test]
fn regex_replace_first_only() {
let mut s = "foo foo foo".to_string();
let changed = replace_first_match(&mut s, "foo", "bar", true, true).unwrap();
assert!(changed);
assert_eq!(s, "bar foo foo");
let changed2 = replace_first_match(&mut s, "foo", "baz", true, true).unwrap();
assert!(changed2);
assert_eq!(s, "bar baz foo");
}
#[test]
fn regex_replace_all_multiple_words() {
let mut s = "cat dog cat bird cat".to_string();
let n = replace_all_matches(&mut s, r"\bcat\b", "X", true, true).unwrap();
assert_eq!(n, 3);
assert_eq!(s, "X dog X bird X");
}
#[test]
fn regex_replace_with_capture_and_newline() {
let mut s = "p:q;r:s".to_string();
let n = replace_all_matches(&mut s, r"(\w):(\w)", r"$1=\n$2", true, true).unwrap();
assert_eq!(n, 2);
assert_eq!(s, "p=\nq;r=\ns");
}
#[test]
fn regex_replace_literal_backslash_n_not_newline() {
let mut s = "x".to_string();
replace_first_match(&mut s, "x", r"\\n", true, true).unwrap();
assert_eq!(s, "\\n");
}
#[test]
fn literal_mode_does_not_expand_escapes() {
let mut s = "a,a,a".to_string();
let n = replace_all_matches(&mut s, ",", r"\n", true, false).unwrap();
assert_eq!(n, 2);
assert_eq!(s, r"a\na\na");
}
#[test]
fn case_insensitive_literal_replace_all() {
let mut s = "Ab ab AB".to_string();
let n = replace_all_matches(&mut s, "ab", "z", false, false).unwrap();
assert_eq!(n, 3);
assert_eq!(s, "z z z");
}
#[test]
fn regex_invalid_pattern_errors() {
let mut s = "hi".to_string();
assert!(replace_all_matches(&mut s, "(", "x", true, true).is_err());
}
#[test]
fn empty_query_no_op() {
let mut s = "abc".to_string();
assert_eq!(
replace_all_matches(&mut s, "", "x", true, true).unwrap(),
0
);
assert_eq!(s, "abc");
}
#[test]
fn regex_replace_all_dollar_literal_via_escape() {
let mut s = "price: 5".to_string();
let n = replace_all_matches(&mut s, r"\d", r"\$", true, true).unwrap();
assert_eq!(n, 1);
assert_eq!(s, "price: $");
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Parse, format, and diagnose structured text.
use serde::{Deserialize, Serialize};
use crate::document::language::LanguageId;
/// Editor diagnostic (error or warning).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Diagnostic {
pub message: String,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// Message returned when formatting plain or unknown buffers (no structured formatter).
pub const NO_FORMATTER_PLAIN_MSG: &str = "No formatter for plain/unknown text";
impl Diagnostic {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
line: None,
column: None,
}
}
#[must_use]
pub fn with_line_col(mut self, line: u32, column: u32) -> Self {
self.line = Some(line);
self.column = Some(column);
self
}
}
/// Format `text` as `language`. On failure returns diagnostics instead of panicking.
pub fn format_document(language: LanguageId, text: &str) -> Result<String, Vec<Diagnostic>> {
match language {
LanguageId::Json => format_json(text),
LanguageId::Yaml => format_yaml(text),
LanguageId::Toml => format_toml(text),
LanguageId::Xml => format_xml(text),
LanguageId::Plain | LanguageId::Unknown => {
Err(vec![Diagnostic::new(NO_FORMATTER_PLAIN_MSG)])
}
}
}
fn format_json(text: &str) -> Result<String, Vec<Diagnostic>> {
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| {
vec![Diagnostic::new(e.to_string()).with_line_col(
e.line() as u32,
e.column() as u32,
)]
})?;
serde_json::to_string_pretty(&v).map_err(|e| vec![Diagnostic::new(e.to_string())])
}
fn format_yaml(text: &str) -> Result<String, Vec<Diagnostic>> {
let v: serde_yaml::Value =
serde_yaml::from_str(text).map_err(|e| vec![Diagnostic::new(e.to_string())])?;
serde_yaml::to_string(&v).map_err(|e| vec![Diagnostic::new(e.to_string())])
}
fn format_toml(text: &str) -> Result<String, Vec<Diagnostic>> {
let v: toml::Value =
toml::from_str(text).map_err(|e| vec![Diagnostic::new(format!("{e}"))])?;
toml::to_string_pretty(&v).map_err(|e| vec![Diagnostic::new(e.to_string())])
}
fn format_xml(text: &str) -> Result<String, Vec<Diagnostic>> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(text);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut depth = 0i32;
let mut out = String::new();
let mut last_was_text = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
if last_was_text {
out.push('\n');
}
out.push_str(&" ".repeat(depth.max(0) as usize));
out.push('<');
out.push_str(&String::from_utf8_lossy(e.name().as_ref()));
for a in e.attributes().flatten() {
out.push(' ');
out.push_str(&String::from_utf8_lossy(a.key.as_ref()));
out.push('=');
out.push('"');
out.push_str(&String::from_utf8_lossy(&a.value));
out.push('"');
}
out.push('>');
out.push('\n');
depth += 1;
last_was_text = false;
}
Ok(Event::End(_)) => {
depth = (depth - 1).max(0);
last_was_text = false;
}
Ok(Event::Empty(e)) => {
if last_was_text {
out.push('\n');
}
out.push_str(&" ".repeat(depth.max(0) as usize));
out.push('<');
out.push_str(&String::from_utf8_lossy(e.name().as_ref()));
for a in e.attributes().flatten() {
out.push(' ');
out.push_str(&String::from_utf8_lossy(a.key.as_ref()));
out.push('=');
out.push('"');
out.push_str(&String::from_utf8_lossy(&a.value));
out.push('"');
}
out.push_str("/>\n");
last_was_text = false;
}
Ok(Event::Text(t)) => {
let s = t
.unescape()
.map_err(|e| vec![Diagnostic::new(format!("XML unescape: {e}"))])?;
let trimmed = s.trim();
if !trimmed.is_empty() {
out.push_str(&" ".repeat(depth.max(0) as usize));
out.push_str(trimmed);
out.push('\n');
last_was_text = true;
}
}
Ok(Event::Comment(c)) => {
out.push_str(&" ".repeat(depth.max(0) as usize));
out.push_str("<!--");
out.push_str(&String::from_utf8_lossy(c.as_ref()));
out.push_str("-->\n");
last_was_text = false;
}
Ok(Event::Decl(_)) => {
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
last_was_text = false;
}
Ok(Event::PI(p)) => {
out.push_str("<?");
out.push_str(&String::from_utf8_lossy(p.as_ref()));
out.push_str("?>\n");
last_was_text = false;
}
Ok(Event::DocType(d)) => {
out.push_str("<!DOCTYPE");
out.push_str(&String::from_utf8_lossy(d.as_ref()));
out.push_str(">\n");
last_was_text = false;
}
Ok(Event::CData(c)) => {
out.push_str(&" ".repeat(depth.max(0) as usize));
out.push_str("<![CDATA[");
out.push_str(&String::from_utf8_lossy(c.as_ref()));
out.push_str("]]>\n");
last_was_text = false;
}
Ok(Event::Eof) => break,
Err(e) => return Err(vec![Diagnostic::new(e.to_string())]),
}
buf.clear();
}
Ok(out.trim_end().to_string() + "\n")
}
/// Collect diagnostics without formatting (parse-only).
///
/// Plain / unknown buffers have no structured rules to check — returns an empty list (no
/// "no formatter" noise in the UI).
#[must_use]
pub fn lint_document(language: LanguageId, text: &str) -> Vec<Diagnostic> {
match language {
LanguageId::Plain | LanguageId::Unknown => Vec::new(),
_ => format_document(language, text).err().unwrap_or_default(),
}
}
@@ -0,0 +1,31 @@
//! Human-facing language / format identifiers.
use serde::{Deserialize, Serialize};
/// Languages supported for detection, formatting, and UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LanguageId {
#[default]
Plain,
Json,
Yaml,
Toml,
Xml,
#[serde(other)]
Unknown,
}
impl LanguageId {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Plain => "Plain Text",
Self::Json => "JSON",
Self::Yaml => "YAML",
Self::Toml => "TOML",
Self::Xml => "XML",
Self::Unknown => "Unknown",
}
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Document language, find/replace, and formatting utilities.
pub mod find_replace;
pub mod format;
pub mod language;
pub use find_replace::{
collect_find_matches, expand_regex_replacement, replace_all_matches, replace_first_match,
};
pub use format::{format_document, lint_document, Diagnostic, NO_FORMATTER_PLAIN_MSG};
pub use language::LanguageId;
+11
View File
@@ -0,0 +1,11 @@
//! Editor workspace model: sessions, tabs, project root.
pub mod session;
pub mod tab;
pub mod view_kind;
pub mod workspace;
pub use session::{load_session, save_session, SessionData, MAX_SESSION_FILE_BYTES, SESSION_SCHEMA_VERSION};
pub use tab::{two_tabs_mut, Tab};
pub use view_kind::ViewKind;
pub use workspace::Workspace;
+97
View File
@@ -0,0 +1,97 @@
//! Session persistence (atomic save, size limits).
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::system::error::{ByteDraftError, Result};
use crate::editor::tab::Tab;
pub const SESSION_SCHEMA_VERSION: u32 = 1;
/// Maximum session file size before parse (deny large allocations).
pub const MAX_SESSION_FILE_BYTES: u64 = 256 * 1024 * 1024;
/// Serializable session snapshot.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionData {
pub schema: u32,
pub active_tab: usize,
/// Optional second tab index for split view (`None` = no split).
pub split_secondary: Option<usize>,
pub tabs: Vec<Tab>,
}
impl SessionData {
#[must_use]
pub fn empty() -> Self {
Self {
schema: SESSION_SCHEMA_VERSION,
active_tab: 0,
split_secondary: None,
tabs: vec![Tab::new_untitled()],
}
}
/// Fix indices and ensure at least one tab.
pub fn normalize(&mut self) {
if self.tabs.is_empty() {
self.tabs.push(Tab::new_untitled());
self.active_tab = 0;
self.split_secondary = None;
return;
}
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len().saturating_sub(1);
}
if let Some(s) = self.split_secondary {
if s >= self.tabs.len() || s == self.active_tab {
self.split_secondary = None;
}
}
}
}
/// Write session atomically: temp file in same directory, then rename.
pub fn save_session(path: &Path, data: &SessionData) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(data)?;
let tmp = path.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp)?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
Ok(())
}
/// Load session with size cap and schema handling.
pub fn load_session(path: &Path) -> Result<SessionData> {
let meta = fs::metadata(path)?;
let len = meta.len();
if len > MAX_SESSION_FILE_BYTES {
return Err(ByteDraftError::SessionTooLarge {
size: len,
max: MAX_SESSION_FILE_BYTES,
});
}
let f = fs::File::open(path)?;
let mut buf = Vec::new();
f.take(MAX_SESSION_FILE_BYTES + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_SESSION_FILE_BYTES {
return Err(ByteDraftError::SessionTooLarge {
size: buf.len() as u64,
max: MAX_SESSION_FILE_BYTES,
});
}
let mut data: SessionData = serde_json::from_slice(&buf)?;
if data.schema != SESSION_SCHEMA_VERSION {
return Err(ByteDraftError::UnsupportedSessionSchema(data.schema));
}
data.normalize();
Ok(data)
}
+129
View File
@@ -0,0 +1,129 @@
//! Open document tab model.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::document::format::Diagnostic;
use crate::document::language::LanguageId;
use crate::editor::view_kind::ViewKind;
/// One open editor tab.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tab {
pub text: String,
pub path: Option<PathBuf>,
pub view_kind: ViewKind,
#[serde(default)]
pub language_override: Option<LanguageId>,
/// Last auto-detected language (recomputed when text changes).
#[serde(default)]
pub detected_language: LanguageId,
#[serde(default)]
pub dirty: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
/// Two distinct mutable tabs in one slice (for split editor).
///
/// # Panics
/// Never; returns `None` if indices invalid or equal.
#[must_use]
pub fn two_tabs_mut(tabs: &mut [Tab], i: usize, j: usize) -> Option<(&mut Tab, &mut Tab)> {
if i == j || i >= tabs.len() || j >= tabs.len() {
return None;
}
if i < j {
let (left, right) = tabs.split_at_mut(j);
Some((&mut left[i], &mut right[0]))
} else {
let (left, right) = tabs.split_at_mut(i);
Some((&mut right[0], &mut left[j]))
}
}
impl Tab {
#[must_use]
pub fn new_untitled() -> Self {
Self {
text: String::new(),
path: None,
view_kind: ViewKind::default(),
language_override: None,
detected_language: LanguageId::Plain,
dirty: false,
diagnostics: Vec::new(),
}
}
#[must_use]
pub fn from_path_and_text(path: PathBuf, text: String) -> Self {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_string);
let detected = crate::system::detect::detect_language(&text, ext.as_deref());
Self {
text,
path: Some(path),
view_kind: ViewKind::default(),
language_override: None,
detected_language: detected,
dirty: false,
diagnostics: Vec::new(),
}
}
#[must_use]
pub fn effective_language(&self) -> LanguageId {
self.language_override.unwrap_or(self.detected_language)
}
/// File extension hint for the path (e.g. `"toml"`), if any.
#[must_use]
pub fn extension_hint(&self) -> Option<&str> {
self.path
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
}
/// Tab label without sibling context (file name, or `"Untitled"` when unsaved).
#[must_use]
pub fn title(&self) -> String {
if let Some(p) = &self.path {
return p
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".to_string());
}
"Untitled".to_string()
}
/// Tab title in the tab strip: disambiguates multiple unsaved buffers as `Untitled`, `Untitled 2`, …
#[must_use]
pub fn tab_label(tabs: &[Tab], index: usize) -> String {
let Some(tab) = tabs.get(index) else {
return "Untitled".to_string();
};
if tab.path.is_some() {
return tab.title();
}
let pathless: Vec<usize> = tabs
.iter()
.enumerate()
.filter(|(_, t)| t.path.is_none())
.map(|(i, _)| i)
.collect();
if pathless.len() <= 1 {
return "Untitled".to_string();
}
let rank = pathless.iter().position(|&i| i == index).unwrap_or(0);
if rank == 0 {
"Untitled".to_string()
} else {
format!("Untitled {}", rank + 1)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Tab view mode (text now, hex later).
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ViewKind {
#[default]
Text,
/// Reserved for binary / hex viewer (not implemented in MVP).
Hex,
}
+20
View File
@@ -0,0 +1,20 @@
//! Optional project folder root (sidebar / future tree).
use std::path::PathBuf;
/// Workspace: opened folder, if any.
#[derive(Debug, Clone, Default)]
pub struct Workspace {
pub root: Option<PathBuf>,
}
impl Workspace {
#[must_use]
pub fn display_name(&self) -> String {
self.root
.as_ref()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "No folder".to_string())
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Logical editor actions (GUI maps input here).
/// High-level commands decoupled from any GUI framework.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
NewTab,
CloseTab,
NextTab,
PrevTab,
OpenFile,
Save,
SaveAs,
FormatDocument,
ToggleSidebar,
ToggleLanguagePicker,
OpenFolder,
}
+5
View File
@@ -0,0 +1,5 @@
//! Logical input mapping (framework-agnostic).
pub mod keymap;
pub use keymap::Action;
+26
View File
@@ -0,0 +1,26 @@
//! ByteDraft — tabbed scratch editor core (no GUI).
#![allow(clippy::module_name_repetitions)]
pub mod document;
pub mod editor;
pub mod input;
pub mod platform;
pub mod plugins;
pub mod system;
pub use document::{
collect_find_matches, expand_regex_replacement, format_document, lint_document,
replace_all_matches, replace_first_match, Diagnostic, LanguageId, NO_FORMATTER_PLAIN_MSG,
};
pub use editor::{
load_session, save_session, two_tabs_mut, SessionData, Tab, ViewKind, Workspace,
MAX_SESSION_FILE_BYTES, SESSION_SCHEMA_VERSION,
};
pub use input::Action;
pub use platform::{
detect_title_bar_host, TitleBarHost, TitleBarPlan, TITLE_BAR_H, WINDOWS_MENU_BAR_ROW_H,
};
pub use system::{
detect_from_content, detect_language, read_text_file_capped, write_text_file_atomic,
ByteDraftError, Formatter, HandlerRegistry, LanguageDetector, Result, MAX_TEXT_FILE_BYTES,
};
+7
View File
@@ -0,0 +1,7 @@
//! Platform-specific constants and detection (no GUI framework).
pub mod title_bar;
pub use title_bar::{
detect_title_bar_host, TitleBarHost, TitleBarPlan, TITLE_BAR_H, WINDOWS_MENU_BAR_ROW_H,
};
@@ -0,0 +1,40 @@
//! Frameless window title bar: host detection and layout constants (no GUI framework).
/// Target look-and-feel for embedded window controls (UI convention only).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TitleBarHost {
Windows,
Macos,
Linux,
}
#[must_use]
pub fn detect_title_bar_host() -> TitleBarHost {
if cfg!(target_os = "macos") {
TitleBarHost::Macos
} else if cfg!(target_os = "windows") {
TitleBarHost::Windows
} else {
TitleBarHost::Linux
}
}
/// Per-frame title bar configuration.
#[derive(Clone, Debug)]
pub struct TitleBarPlan {
pub host: TitleBarHost,
pub title: String,
}
impl Default for TitleBarPlan {
fn default() -> Self {
Self {
host: detect_title_bar_host(),
title: "ByteDraft".to_string(),
}
}
}
pub const TITLE_BAR_H: f32 = 32.0;
/// Second row under the title strip (Windows / Linux): menu bar height.
pub const WINDOWS_MENU_BAR_ROW_H: f32 = 26.0;
+55
View File
@@ -0,0 +1,55 @@
//! Built-in formatters and detectors registered at startup.
use std::sync::Arc;
use crate::system::detect;
use crate::document::format::{format_document, Diagnostic};
use crate::document::language::LanguageId;
use crate::system::registry::HandlerRegistry;
use crate::system::traits::{Formatter, LanguageDetector};
fn first_diag(err: Vec<Diagnostic>) -> Diagnostic {
err.into_iter().next().unwrap_or_else(|| Diagnostic::new("format failed"))
}
macro_rules! delegating_formatter {
($name:ident, $lang:expr) => {
pub struct $name;
impl Formatter for $name {
fn language(&self) -> LanguageId {
$lang
}
fn format(&self, text: &str) -> Result<String, Diagnostic> {
format_document($lang, text).map_err(first_diag)
}
}
};
}
delegating_formatter!(JsonFormatter, LanguageId::Json);
delegating_formatter!(YamlFormatter, LanguageId::Yaml);
delegating_formatter!(TomlFormatter, LanguageId::Toml);
delegating_formatter!(XmlFormatter, LanguageId::Xml);
/// Uses [`detect::detect_language`] heuristics.
pub struct DefaultDetector;
impl LanguageDetector for DefaultDetector {
fn detect(&self, content: &str, extension_hint: Option<&str>) -> Option<LanguageId> {
Some(detect::detect_language(content, extension_hint))
}
}
/// Register all built-in detectors and formatters.
///
/// # Errors
/// Returns if duplicate formatter IDs would be registered.
pub fn register_builtins(registry: &mut HandlerRegistry) -> Result<(), String> {
registry.register_detector(Arc::new(DefaultDetector));
registry.register_formatter(Arc::new(JsonFormatter))?;
registry.register_formatter(Arc::new(YamlFormatter))?;
registry.register_formatter(Arc::new(TomlFormatter))?;
registry.register_formatter(Arc::new(XmlFormatter))?;
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
//! Plugin / add-in hooks (built-in registration only for MVP).
pub mod builtin;
+364
View File
@@ -0,0 +1,364 @@
//! Language detection heuristics.
use crate::document::language::LanguageId;
/// Detect language from optional file extension (e.g. `"json"`) and buffer content.
///
/// For ambiguous or invalid content, prefers more popular formats:
/// - JSON-like content (starts with `{` or `[`) is detected as JSON even if invalid/incomplete
/// - JSONC (JSON with comments) is treated as JSON
#[must_use]
pub fn detect_language(content: &str, extension_hint: Option<&str>) -> LanguageId {
if let Some(ext) = extension_hint {
let e = ext.trim_start_matches('.').to_ascii_lowercase();
match e.as_str() {
"json" | "jsonc" => return LanguageId::Json,
"yml" | "yaml" => return LanguageId::Yaml,
"toml" => return LanguageId::Toml,
"xml" | "xhtml" | "svg" | "xsl" | "xslt" => return LanguageId::Xml,
_ => {}
}
}
detect_from_content(content)
}
/// Detect language purely from content, ignoring file extension.
#[must_use]
pub fn detect_from_content(content: &str) -> LanguageId {
let trimmed = content.trim_start();
let first = trimmed.lines().next().unwrap_or("").trim();
// Shebang → plain script
if first.starts_with("#!/") {
return LanguageId::Plain;
}
// XML declaration or DOCTYPE
if first.starts_with("<?xml")
|| trimmed.starts_with("<?xml")
|| first.starts_with("<!DOCTYPE")
|| first.starts_with("<!")
{
return LanguageId::Xml;
}
// YAML front matter or directive
if first == "---" || first.starts_with("%YAML") {
return LanguageId::Yaml;
}
// TOML table header with key=value somewhere
if first.starts_with('[') && first.contains(']') && trimmed.contains('=') {
return LanguageId::Toml;
}
// JSON-like: starts with { or [ — detect as JSON even if invalid/incomplete
// This handles broken JSON, JSONC (comments), trailing commas, etc.
let t = trimmed.trim_start();
if t.starts_with('{') || t.starts_with('[') {
return LanguageId::Json;
}
// XML-like: starts with < and contains closing tag or self-closing
if t.starts_with('<') && (t.contains("</") || t.contains("/>") || t.contains('>')) {
return LanguageId::Xml;
}
// YAML-like: contains colon-space patterns typical of YAML mappings
// but only if it doesn't look like JSON
if looks_like_yaml(trimmed) {
return LanguageId::Yaml;
}
// TOML-like: key = value patterns without looking like other formats
if looks_like_toml(trimmed) {
return LanguageId::Toml;
}
LanguageId::Plain
}
/// Heuristic: content looks like YAML (key: value patterns, not JSON)
fn looks_like_yaml(content: &str) -> bool {
let lines: Vec<&str> = content.lines().take(10).collect();
if lines.is_empty() {
return false;
}
let mut yaml_score = 0;
for line in &lines {
let trimmed = line.trim();
// Skip empty lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// YAML list item
if trimmed.starts_with("- ") {
yaml_score += 1;
}
// key: value (but not inside braces which would be JSON)
if let Some(colon_pos) = trimmed.find(':') {
let before = &trimmed[..colon_pos];
let after = &trimmed[colon_pos + 1..];
// Key should be simple (no quotes typically in YAML keys at start of line)
if !before.contains('"')
&& !before.contains('{')
&& !before.contains('[')
&& (after.starts_with(' ') || after.is_empty())
{
yaml_score += 1;
}
}
}
yaml_score >= 2
}
/// Heuristic: content looks like TOML (key = value patterns)
fn looks_like_toml(content: &str) -> bool {
let lines: Vec<&str> = content.lines().take(10).collect();
if lines.is_empty() {
return false;
}
let mut toml_score = 0;
for line in &lines {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// [section] header
if trimmed.starts_with('[') && trimmed.ends_with(']') {
toml_score += 2;
}
// key = value (with spaces around =)
if trimmed.contains(" = ") {
toml_score += 1;
}
}
toml_score >= 2
}
#[cfg(test)]
mod tests {
use super::*;
// === JSON detection tests ===
#[test]
fn valid_json_object() {
let content = r#"{"key": "value"}"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn valid_json_array() {
let content = r#"[1, 2, 3]"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn incomplete_json_object() {
let content = r#"{"key": 1,"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn incomplete_json_array() {
let content = r#"[1, 2,"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn json_with_trailing_comma() {
let content = r#"{"key": 1,}"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn json_with_comments_jsonc() {
let content = r#"{
// this is a comment
"key": "value"
}"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn json_with_block_comments() {
let content = r#"{
/* block comment */
"key": "value"
}"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn deeply_nested_broken_json() {
let content = r#"{"a": {"b": {"c": {"d":"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn json_missing_closing_brace() {
let content = r#"{"name": "test", "value": 42"#;
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn json_with_whitespace_prefix() {
let content = " \n\n {\"key\": 1}";
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn empty_json_object() {
let content = "{}";
assert_eq!(detect_from_content(content), LanguageId::Json);
}
#[test]
fn empty_json_array() {
let content = "[]";
assert_eq!(detect_from_content(content), LanguageId::Json);
}
// === XML detection tests ===
#[test]
fn xml_declaration() {
let content = r#"<?xml version="1.0"?><root/>"#;
assert_eq!(detect_from_content(content), LanguageId::Xml);
}
#[test]
fn xml_doctype() {
let content = r#"<!DOCTYPE html><html></html>"#;
assert_eq!(detect_from_content(content), LanguageId::Xml);
}
#[test]
fn xml_simple_element() {
let content = "<root><child>text</child></root>";
assert_eq!(detect_from_content(content), LanguageId::Xml);
}
#[test]
fn xml_self_closing() {
let content = "<element attr=\"value\"/>";
assert_eq!(detect_from_content(content), LanguageId::Xml);
}
#[test]
fn broken_xml_unclosed() {
let content = "<root><child>";
assert_eq!(detect_from_content(content), LanguageId::Xml);
}
// === YAML detection tests ===
#[test]
fn yaml_front_matter() {
let content = "---\nkey: value\n---";
assert_eq!(detect_from_content(content), LanguageId::Yaml);
}
#[test]
fn yaml_directive() {
let content = "%YAML 1.2\n---\nkey: value";
assert_eq!(detect_from_content(content), LanguageId::Yaml);
}
#[test]
fn yaml_simple_mapping() {
let content = "name: test\nversion: 1.0\nauthor: someone";
assert_eq!(detect_from_content(content), LanguageId::Yaml);
}
#[test]
fn yaml_with_list() {
let content = "items:\n - one\n - two\n - three";
assert_eq!(detect_from_content(content), LanguageId::Yaml);
}
// === TOML detection tests ===
#[test]
fn toml_section_header() {
let content = "[package]\nname = \"test\"";
assert_eq!(detect_from_content(content), LanguageId::Toml);
}
#[test]
fn toml_key_value() {
let content = "name = \"test\"\nversion = \"1.0\"";
assert_eq!(detect_from_content(content), LanguageId::Toml);
}
#[test]
fn toml_nested_section() {
let content = "[dependencies]\nserde = \"1.0\"\n\n[dev-dependencies]\ntempfile = \"3\"";
assert_eq!(detect_from_content(content), LanguageId::Toml);
}
// === Extension hint tests ===
#[test]
fn extension_json_overrides_content() {
let content = "not json at all";
assert_eq!(detect_language(content, Some("json")), LanguageId::Json);
}
#[test]
fn extension_jsonc_detected_as_json() {
let content = "";
assert_eq!(detect_language(content, Some("jsonc")), LanguageId::Json);
}
#[test]
fn extension_yaml() {
let content = "";
assert_eq!(detect_language(content, Some("yaml")), LanguageId::Yaml);
assert_eq!(detect_language(content, Some("yml")), LanguageId::Yaml);
}
#[test]
fn extension_xml_variants() {
let content = "";
assert_eq!(detect_language(content, Some("xml")), LanguageId::Xml);
assert_eq!(detect_language(content, Some("svg")), LanguageId::Xml);
assert_eq!(detect_language(content, Some("xhtml")), LanguageId::Xml);
}
// === Edge cases ===
#[test]
fn empty_content() {
assert_eq!(detect_from_content(""), LanguageId::Plain);
}
#[test]
fn whitespace_only() {
assert_eq!(detect_from_content(" \n\n "), LanguageId::Plain);
}
#[test]
fn shebang_is_plain() {
let content = "#!/bin/bash\necho hello";
assert_eq!(detect_from_content(content), LanguageId::Plain);
}
#[test]
fn plain_text() {
let content = "This is just some plain text.\nNothing special here.";
assert_eq!(detect_from_content(content), LanguageId::Plain);
}
#[test]
fn ambiguous_prefers_json_over_yaml() {
// Content that starts with [ could be JSON array or YAML flow sequence
// We prefer JSON as it's more common
let content = "[item1, item2]";
assert_eq!(detect_from_content(content), LanguageId::Json);
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Error types for library operations.
use std::path::PathBuf;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ByteDraftError>;
#[derive(Debug, Error)]
pub enum ByteDraftError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
SerdeJson(#[from] serde_json::Error),
#[error("YAML error: {0}")]
SerdeYaml(#[from] serde_yaml::Error),
#[error("TOML error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("TOML parse error: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("XML error: {0}")]
Xml(String),
#[error("Session file too large: {size} bytes (max {max})")]
SessionTooLarge { size: u64, max: u64 },
#[error("Invalid path: {0:?}")]
InvalidPath(PathBuf),
#[error("Unsupported session schema version: {0}")]
UnsupportedSessionSchema(u32),
}
+46
View File
@@ -0,0 +1,46 @@
//! Size-limited file reads for security and stability.
use std::fs;
use std::io::Read;
use std::path::Path;
use crate::system::error::{ByteDraftError, Result};
/// Maximum text file size for open / drag-drop (32 MiB).
pub const MAX_TEXT_FILE_BYTES: u64 = 32 * 1024 * 1024;
/// Read a UTF-8 text file with a size cap. Path is not executed as shell.
pub fn read_text_file_capped(path: &Path) -> Result<String> {
let meta = fs::metadata(path)?;
let len = meta.len();
if len > MAX_TEXT_FILE_BYTES {
return Err(ByteDraftError::SessionTooLarge {
size: len,
max: MAX_TEXT_FILE_BYTES,
});
}
let f = fs::File::open(path)?;
let mut buf = Vec::new();
f.take(MAX_TEXT_FILE_BYTES + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_TEXT_FILE_BYTES {
return Err(ByteDraftError::SessionTooLarge {
size: buf.len() as u64,
max: MAX_TEXT_FILE_BYTES,
});
}
String::from_utf8(buf).map_err(|e| ByteDraftError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("file is not valid UTF-8: {e}"),
)))
}
/// Write text atomically (best-effort) via temp + rename in same directory.
pub fn write_text_file_atomic(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, content.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
+13
View File
@@ -0,0 +1,13 @@
//! System services: error handling, file IO, detection, registry, and traits.
pub mod detect;
pub mod error;
pub mod fs_util;
pub mod registry;
pub mod traits;
pub use detect::{detect_from_content, detect_language};
pub use error::{ByteDraftError, Result};
pub use fs_util::{read_text_file_capped, write_text_file_atomic, MAX_TEXT_FILE_BYTES};
pub use registry::HandlerRegistry;
pub use traits::{Formatter, LanguageDetector};
+59
View File
@@ -0,0 +1,59 @@
//! Pluggable formatters and detectors (built-in only in MVP).
use std::sync::Arc;
use crate::document::format::{format_document, Diagnostic};
use crate::document::language::LanguageId;
use crate::system::traits::{Formatter, LanguageDetector};
/// Central registry for formatters and language detectors.
#[derive(Default)]
pub struct HandlerRegistry {
detectors: Vec<Arc<dyn LanguageDetector>>,
formatters: Vec<Arc<dyn Formatter>>,
}
impl HandlerRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns error if a formatter for this language is already registered.
pub fn register_formatter(&mut self, f: Arc<dyn Formatter>) -> Result<(), String> {
let lang = f.language();
if self
.formatters
.iter()
.any(|x| x.language() == lang)
{
return Err(format!("duplicate formatter for {:?}", lang));
}
self.formatters.push(f);
Ok(())
}
pub fn register_detector(&mut self, d: Arc<dyn LanguageDetector>) {
self.detectors.push(d);
}
#[must_use]
pub fn detect(&self, content: &str, extension_hint: Option<&str>) -> LanguageId {
for d in &self.detectors {
if let Some(lang) = d.detect(content, extension_hint) {
return lang;
}
}
LanguageId::Plain
}
/// Format using the registered formatter for `language`, else built-in [`format_document`].
pub fn format(&self, language: LanguageId, text: &str) -> Result<String, Vec<Diagnostic>> {
for f in &self.formatters {
if f.language() == language {
return f.format(text).map_err(|d| vec![d]);
}
}
format_document(language, text)
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Extension traits for formatters and language detectors.
use crate::document::format::Diagnostic;
use crate::document::language::LanguageId;
/// Formats buffer text for a language (pure, no IO).
pub trait Formatter: Send + Sync {
fn language(&self) -> LanguageId;
/// Returns formatted text or a single diagnostic on failure.
fn format(&self, text: &str) -> Result<String, Diagnostic>;
}
/// Contributes language detection (first non-`None` wins in registration order).
pub trait LanguageDetector: Send + Sync {
fn detect(&self, content: &str, extension_hint: Option<&str>) -> Option<LanguageId>;
}
+48
View File
@@ -0,0 +1,48 @@
//! Unit tests for plugins builtin API.
use byte_draft::system::detect;
use byte_draft::plugins::builtin::{
register_builtins, DefaultDetector, JsonFormatter, TomlFormatter, XmlFormatter, YamlFormatter,
};
use byte_draft::system::registry::HandlerRegistry;
use byte_draft::system::traits::{Formatter, LanguageDetector};
#[test]
fn register_builtins_succeeds() {
let mut r = HandlerRegistry::new();
register_builtins(&mut r).expect("once");
}
#[test]
fn default_detector_matches_detect_crate() {
let d = DefaultDetector;
assert_eq!(
d.detect(r#"{"a":1}"#, None),
Some(detect::detect_language(r#"{"a":1}"#, None))
);
}
#[test]
fn json_formatter_surfaces_parse_error() {
let f = JsonFormatter;
let err = f.format("{").unwrap_err();
assert!(!err.message.is_empty());
}
#[test]
fn yaml_formatter_surfaces_parse_error() {
let err = YamlFormatter.format("foo: [").unwrap_err();
assert!(!err.message.is_empty());
}
#[test]
fn toml_formatter_surfaces_parse_error() {
let err = TomlFormatter.format("a = ").unwrap_err();
assert!(!err.message.is_empty());
}
#[test]
fn xml_formatter_surfaces_parse_error() {
let err = XmlFormatter.format("<bad").unwrap_err();
assert!(!err.message.is_empty());
}
+91
View File
@@ -0,0 +1,91 @@
//! Unit tests for system detect API.
use byte_draft::system::detect::detect_language;
use byte_draft::document::language::LanguageId;
#[test]
fn extension_json() {
assert_eq!(
detect_language("not json", Some("json")),
LanguageId::Json
);
}
#[test]
fn content_json_object() {
assert_eq!(
detect_language(r#"{"a": 1}"#, None),
LanguageId::Json
);
}
#[test]
fn content_yaml_header() {
assert_eq!(
detect_language("---\nfoo: bar\n", None),
LanguageId::Yaml
);
}
#[test]
fn content_xml() {
assert_eq!(
detect_language(r#"<?xml version="1.0"?><r/>"#, None),
LanguageId::Xml
);
}
#[test]
fn extensions_map_before_content() {
assert_eq!(
detect_language("not valid json", Some("json")),
LanguageId::Json
);
assert_eq!(detect_language("x", Some(".YAML")), LanguageId::Yaml);
assert_eq!(detect_language("x", Some("toml")), LanguageId::Toml);
assert_eq!(detect_language("x", Some("jsonc")), LanguageId::Json);
assert_eq!(detect_language("x", Some("yml")), LanguageId::Yaml);
assert_eq!(detect_language("x", Some("xhtml")), LanguageId::Xml);
}
#[test]
fn shebang_is_plain() {
assert_eq!(
detect_language("#!/bin/bash\necho hi", None),
LanguageId::Plain
);
}
#[test]
fn yaml_percent_directive() {
assert_eq!(
detect_language("%YAML 1.1\n---\n", None),
LanguageId::Yaml
);
}
#[test]
fn toml_table_header() {
assert_eq!(
detect_language("[section]\nkey = 1\n", None),
LanguageId::Toml
);
}
#[test]
fn json_array_content() {
assert_eq!(detect_language("[1, 2, 3]", None), LanguageId::Json);
}
#[test]
fn invalid_json_object_detected_as_json() {
assert_eq!(detect_language("{ not json", None), LanguageId::Json);
}
#[test]
fn unknown_extension_falls_through_to_content() {
assert_eq!(
detect_language(r#"{"a":1}"#, Some("txt")),
LanguageId::Json
);
}
+28
View File
@@ -0,0 +1,28 @@
//! Unit tests for system error API.
use std::path::PathBuf;
use byte_draft::system::error::ByteDraftError;
#[test]
fn session_too_large_display() {
let e = ByteDraftError::SessionTooLarge {
size: 10,
max: 5,
};
let s = e.to_string();
assert!(s.contains("10"));
assert!(s.contains("5"));
}
#[test]
fn unsupported_schema_display() {
let e = ByteDraftError::UnsupportedSessionSchema(42);
assert!(e.to_string().contains("42"));
}
#[test]
fn invalid_path_display() {
let e = ByteDraftError::InvalidPath(PathBuf::from("weird"));
assert!(!e.to_string().is_empty());
}
+138
View File
@@ -0,0 +1,138 @@
//! Unit tests for document format API.
use std::collections::BTreeMap;
use byte_draft::document::format::{format_document, lint_document, Diagnostic};
use byte_draft::document::language::LanguageId;
#[test]
fn json_format_roundtrip() {
let ugly = r#"{"a":1,"b":[2,3]}"#;
let pretty = format_document(LanguageId::Json, ugly).expect("format");
assert!(pretty.contains('\n'));
let v2: BTreeMap<String, serde_json::Value> =
serde_json::from_str(&pretty).expect("reparse");
assert!(v2.contains_key("a"));
}
#[test]
fn json_invalid_line_col() {
let bad = "{\n \"x\": ,\n}";
let err = format_document(LanguageId::Json, bad).expect_err("should fail");
assert!(!err.is_empty());
assert!(err[0].line.is_some());
}
#[test]
fn toml_format() {
let src = "a=1\nb=\"x\"\n";
let out = format_document(LanguageId::Toml, src).expect("format");
assert!(out.contains('a'));
}
#[test]
fn xml_decl_preserved() {
let src = r#"<?xml version="1.0"?><root><a/></root>"#;
let out = format_document(LanguageId::Xml, src).expect("format");
assert!(out.contains("<?xml"));
assert!(out.contains("<root>"));
}
#[test]
fn plain_and_unknown_reject_format() {
for lang in [LanguageId::Plain, LanguageId::Unknown] {
let err = format_document(lang, "anything").unwrap_err();
assert_eq!(err.len(), 1);
assert!(err[0].message.contains("formatter"));
}
}
#[test]
fn yaml_format_roundtrip() {
let src = "foo: bar\nlist:\n - 1\n";
let out = format_document(LanguageId::Yaml, src).expect("format");
let again = format_document(LanguageId::Yaml, &out).expect("reformat");
let v1: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
let v2: serde_yaml::Value = serde_yaml::from_str(&again).unwrap();
assert_eq!(v1, v2);
}
#[test]
fn yaml_invalid_returns_diagnostic() {
let err = format_document(LanguageId::Yaml, "foo: [").unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn lint_empty_on_success() {
assert!(lint_document(LanguageId::Json, "{}").is_empty());
}
#[test]
fn lint_plain_and_unknown_empty() {
assert!(lint_document(LanguageId::Plain, "anything").is_empty());
assert!(lint_document(LanguageId::Unknown, "anything").is_empty());
}
#[test]
fn lint_collects_on_failure() {
let d = lint_document(LanguageId::Json, "{");
assert!(!d.is_empty());
}
#[test]
fn xml_comment_and_cdata() {
let src = r#"<?xml version="1.0"?><r><!--hi--><![CDATA[x&y]]></r>"#;
let out = format_document(LanguageId::Xml, src).expect("format");
assert!(out.contains("<!--hi-->"));
assert!(out.contains("CDATA"));
}
#[test]
fn xml_malformed_returns_error() {
let err = format_document(LanguageId::Xml, "<unclosed").unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn diagnostic_builder() {
let d = Diagnostic::new("m").with_line_col(2, 3);
assert_eq!(d.line, Some(2));
assert_eq!(d.column, Some(3));
}
#[test]
fn toml_invalid_returns_diagnostic() {
let err = format_document(LanguageId::Toml, "a = ").unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn xml_pi_and_doctype_emitted() {
let src = concat!(
r#"<?xml version="1.0"?>"#,
"<!DOCTYPE r [<!ELEMENT r EMPTY>]>",
"<?xml-stylesheet href=\"s\"?>",
"<r/>",
);
let out = format_document(LanguageId::Xml, src).expect("format");
assert!(out.contains("<!DOCTYPE"));
assert!(out.contains("xml-stylesheet") || out.contains("stylesheet"));
}
#[test]
fn xml_empty_element_with_attributes() {
let src = r#"<?xml version="1.0"?><box a="1" b="two"/>"#;
let out = format_document(LanguageId::Xml, src).expect("format");
assert!(out.contains("a=\""));
assert!(out.contains("/>"));
}
#[test]
fn json_format_preserves_array_root() {
let src = "[true, null, 2]";
let out = format_document(LanguageId::Json, src).expect("format");
assert!(out.contains('\n'));
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v.is_array());
}
+52
View File
@@ -0,0 +1,52 @@
//! Unit tests for system fs_util API.
use std::fs;
use std::fs::OpenOptions;
use byte_draft::system::fs_util::{
read_text_file_capped, write_text_file_atomic, MAX_TEXT_FILE_BYTES,
};
use tempfile::tempdir;
#[test]
fn read_text_file_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("f.txt");
write_text_file_atomic(&path, "hello 世界").unwrap();
let s = read_text_file_capped(&path).unwrap();
assert_eq!(s, "hello 世界");
}
#[test]
fn read_rejects_non_utf8() {
let dir = tempdir().unwrap();
let path = dir.path().join("b.bin");
fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
let err = read_text_file_capped(&path).unwrap_err();
assert!(err.to_string().contains("UTF-8") || err.to_string().contains("utf"));
}
#[test]
fn read_rejects_too_large_by_metadata() {
let dir = tempdir().unwrap();
let path = dir.path().join("huge.txt");
let f = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&path)
.unwrap();
f.set_len(MAX_TEXT_FILE_BYTES + 1).unwrap();
drop(f);
let err = read_text_file_capped(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("large") || msg.contains("bytes"));
}
#[test]
fn write_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("a.txt");
write_text_file_atomic(&path, "x").unwrap();
assert_eq!(read_text_file_capped(&path).unwrap(), "x");
}
+10
View File
@@ -0,0 +1,10 @@
//! Unit tests for input keymap API.
use byte_draft::input::keymap::Action;
#[test]
fn action_copy_eq() {
let a = Action::NewTab;
assert_eq!(a, Action::NewTab);
assert_ne!(a, Action::CloseTab);
}
+32
View File
@@ -0,0 +1,32 @@
//! Unit tests for document language API.
use byte_draft::document::language::LanguageId;
#[test]
fn as_str_all_variants() {
assert_eq!(LanguageId::Plain.as_str(), "Plain Text");
assert_eq!(LanguageId::Json.as_str(), "JSON");
assert_eq!(LanguageId::Yaml.as_str(), "YAML");
assert_eq!(LanguageId::Toml.as_str(), "TOML");
assert_eq!(LanguageId::Xml.as_str(), "XML");
assert_eq!(LanguageId::Unknown.as_str(), "Unknown");
}
#[test]
fn serde_roundtrip_known() {
let v = serde_json::to_string(&LanguageId::Yaml).unwrap();
assert_eq!(v, "\"yaml\"");
let id: LanguageId = serde_json::from_str(&v).unwrap();
assert_eq!(id, LanguageId::Yaml);
}
#[test]
fn serde_unknown_tag_maps_to_unknown() {
let id: LanguageId = serde_json::from_str("\"fortran\"").unwrap();
assert_eq!(id, LanguageId::Unknown);
}
#[test]
fn default_is_plain() {
assert_eq!(LanguageId::default(), LanguageId::Plain);
}
+64
View File
@@ -0,0 +1,64 @@
//! Unit tests for system registry API.
use std::sync::Arc;
use byte_draft::document::format::Diagnostic;
use byte_draft::document::language::LanguageId;
use byte_draft::plugins::builtin::{self, JsonFormatter};
use byte_draft::system::registry::HandlerRegistry;
use byte_draft::system::traits::Formatter;
struct UpperJsonFormatter;
impl Formatter for UpperJsonFormatter {
fn language(&self) -> LanguageId {
LanguageId::Json
}
fn format(&self, text: &str) -> Result<String, Diagnostic> {
Ok(text.to_ascii_uppercase())
}
}
#[test]
fn no_duplicate_builtin_formatters() {
let mut r = HandlerRegistry::new();
builtin::register_builtins(&mut r).expect("builtins");
let dup = Arc::new(JsonFormatter);
assert!(r.register_formatter(dup).is_err());
}
#[test]
fn empty_registry_detect_is_plain() {
let r = HandlerRegistry::new();
assert_eq!(r.detect(r#"{"a":1}"#, None), LanguageId::Plain);
}
#[test]
fn format_falls_back_to_builtin_without_plugin() {
let r = HandlerRegistry::new();
let out = r
.format(LanguageId::Json, r#"{"z":1}"#)
.expect("builtin json");
assert!(out.contains('\n'));
}
#[test]
fn registered_formatter_takes_precedence() {
let mut r = HandlerRegistry::new();
r.register_formatter(Arc::new(UpperJsonFormatter))
.expect("register");
let out = r.format(LanguageId::Json, r#"{"a":true}"#).unwrap();
assert!(out.contains("TRUE") || out.contains("A"));
}
#[test]
fn register_duplicate_formatter_err_message() {
let mut r = HandlerRegistry::new();
r.register_formatter(Arc::new(UpperJsonFormatter))
.expect("first");
let err = r
.register_formatter(Arc::new(UpperJsonFormatter))
.unwrap_err();
assert!(err.contains("duplicate") || err.contains("Json"));
}
+142
View File
@@ -0,0 +1,142 @@
//! Unit tests for editor session API.
use std::fs;
use std::fs::OpenOptions;
use std::path::PathBuf;
use byte_draft::editor::session::{
load_session, save_session, SessionData, MAX_SESSION_FILE_BYTES, SESSION_SCHEMA_VERSION,
};
use byte_draft::editor::tab::Tab;
use tempfile::tempdir;
#[test]
fn roundtrip_session() {
let dir = tempdir().unwrap();
let path = dir.path().join("session.json");
let mut data = SessionData::empty();
data.tabs[0].text = "hello".to_string();
data.tabs[0].dirty = true;
save_session(&path, &data).unwrap();
let loaded = load_session(&path).unwrap();
assert_eq!(loaded.tabs[0].text, "hello");
assert!(loaded.tabs[0].dirty);
}
#[test]
fn corrupt_json_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.json");
fs::write(&path, b"{ not json").unwrap();
assert!(load_session(&path).is_err());
}
#[test]
fn normalize_empty_tabs() {
let mut s = SessionData {
schema: SESSION_SCHEMA_VERSION,
active_tab: 0,
split_secondary: None,
tabs: vec![],
};
s.normalize();
assert_eq!(s.tabs.len(), 1);
}
#[test]
fn normalize_clamps_active_tab() {
let mut s = SessionData {
schema: SESSION_SCHEMA_VERSION,
active_tab: 99,
split_secondary: None,
tabs: vec![Tab::new_untitled(), Tab::new_untitled()],
};
s.normalize();
assert_eq!(s.active_tab, 1);
}
#[test]
fn normalize_clears_invalid_split() {
let mut s = SessionData {
schema: SESSION_SCHEMA_VERSION,
active_tab: 0,
split_secondary: Some(0),
tabs: vec![Tab::new_untitled(), Tab::new_untitled()],
};
s.normalize();
assert_eq!(s.split_secondary, None);
let mut s2 = SessionData {
schema: SESSION_SCHEMA_VERSION,
active_tab: 0,
split_secondary: Some(5),
tabs: vec![Tab::new_untitled()],
};
s2.normalize();
assert_eq!(s2.split_secondary, None);
}
#[test]
fn load_missing_file_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("nope.json");
assert!(load_session(&path).is_err());
}
#[test]
fn load_rejects_oversized_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("big.json");
let f = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&path)
.unwrap();
f.set_len(MAX_SESSION_FILE_BYTES + 1).unwrap();
drop(f);
let err = load_session(&path).unwrap_err();
assert!(
err.to_string().contains("large") || err.to_string().contains("bytes"),
"{err}"
);
}
#[test]
fn save_and_load_tabs() {
let dir = tempdir().unwrap();
let path: PathBuf = dir.path().join("session.json");
let mut data = SessionData::empty();
data.tabs[0].text = "{\"a\": 1}".to_string();
data.active_tab = 0;
save_session(&path, &data).unwrap();
let loaded = load_session(&path).unwrap();
assert_eq!(loaded.tabs[0].text, "{\"a\": 1}");
}
#[test]
fn unsupported_schema_returns_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("session.json");
let raw = r#"{"schema":999,"active_tab":0,"split_secondary":null,"tabs":[]}"#;
fs::write(&path, raw).unwrap();
let err = load_session(&path).unwrap_err();
let s = err.to_string();
assert!(s.contains("999") || s.contains("schema"));
}
#[test]
fn split_secondary_roundtrip_when_valid() {
let dir = tempdir().unwrap();
let path: PathBuf = dir.path().join("session.json");
let mut data = SessionData::empty();
data.tabs.push(Tab::new_untitled());
data.tabs[0].text = "left".into();
data.tabs[1].text = "right".into();
data.active_tab = 0;
data.split_secondary = Some(1);
save_session(&path, &data).unwrap();
let loaded = load_session(&path).unwrap();
assert_eq!(loaded.split_secondary, Some(1));
assert_eq!(loaded.tabs[1].text, "right");
}
+126
View File
@@ -0,0 +1,126 @@
//! Unit tests for editor tab API.
use std::path::PathBuf;
use byte_draft::document::language::LanguageId;
use byte_draft::editor::tab::{two_tabs_mut, Tab};
use byte_draft::editor::view_kind::ViewKind;
#[test]
fn title_from_path() {
let t = Tab::from_path_and_text(PathBuf::from("/foo/bar.json"), "{}".to_string());
assert_eq!(t.title(), "bar.json");
}
#[test]
fn title_untitled_empty() {
let t = Tab::new_untitled();
assert_eq!(t.title(), "Untitled");
}
#[test]
fn effective_language_override() {
let mut t = Tab::from_path_and_text(PathBuf::from("x.json"), "{}".to_string());
t.language_override = Some(LanguageId::Yaml);
assert_eq!(t.effective_language(), LanguageId::Yaml);
}
#[test]
fn extension_hint_from_path() {
let t = Tab::from_path_and_text(PathBuf::from("demo.toml"), String::new());
assert_eq!(t.extension_hint(), Some("toml"));
}
#[test]
fn extension_hint_untitled_is_none() {
let t = Tab::new_untitled();
assert_eq!(t.extension_hint(), None);
}
#[test]
fn two_tabs_disjoint() {
let mut tabs = vec![Tab::new_untitled(), Tab::new_untitled()];
tabs[0].text = "a".into();
tabs[1].text = "b".into();
let (x, y) = two_tabs_mut(&mut tabs, 0, 1).unwrap();
assert_eq!(x.text, "a");
assert_eq!(y.text, "b");
y.text = "c".into();
assert_eq!(tabs[1].text, "c");
}
#[test]
fn two_tabs_mut_reverse_order_same_as_forward() {
let mut tabs = vec![
Tab::new_untitled(),
Tab::new_untitled(),
Tab::new_untitled(),
];
tabs[0].text = "a".into();
tabs[2].text = "c".into();
let (x, y) = two_tabs_mut(&mut tabs, 2, 0).unwrap();
assert_eq!(x.text, "c");
assert_eq!(y.text, "a");
}
#[test]
fn two_tabs_mut_invalid_returns_none() {
let mut tabs = vec![Tab::new_untitled(), Tab::new_untitled()];
assert!(two_tabs_mut(&mut tabs, 0, 0).is_none());
assert!(two_tabs_mut(&mut tabs, 0, 9).is_none());
assert!(two_tabs_mut(&mut tabs, 9, 0).is_none());
}
#[test]
fn title_untitled_ignores_first_line() {
let mut t = Tab::new_untitled();
let line: String = "x".repeat(50);
t.text = format!("{line}\nmore");
assert_eq!(t.title(), "Untitled");
}
#[test]
fn tab_label_single_untitled() {
let tabs = vec![Tab::new_untitled()];
assert_eq!(Tab::tab_label(&tabs, 0), "Untitled");
}
#[test]
fn tab_label_two_untitled() {
let tabs = vec![Tab::new_untitled(), Tab::new_untitled()];
assert_eq!(Tab::tab_label(&tabs, 0), "Untitled");
assert_eq!(Tab::tab_label(&tabs, 1), "Untitled 2");
}
#[test]
fn tab_label_mixed_path_and_untitled() {
let tabs = vec![
Tab::from_path_and_text(PathBuf::from("/a/x.rs"), String::new()),
Tab::new_untitled(),
];
assert_eq!(Tab::tab_label(&tabs, 0), "x.rs");
assert_eq!(Tab::tab_label(&tabs, 1), "Untitled");
}
#[test]
fn title_untitled_whitespace_only() {
let mut t = Tab::new_untitled();
t.text = " \n \t ".into();
assert_eq!(t.title(), "Untitled");
}
#[test]
fn title_path_without_file_name_falls_back() {
let t = Tab::from_path_and_text(PathBuf::from(""), "hi".into());
assert_eq!(t.title(), "untitled");
}
#[test]
fn view_kind_roundtrip_in_json() {
let mut t = Tab::new_untitled();
t.view_kind = ViewKind::Hex;
let v = serde_json::to_string(&t).unwrap();
assert!(v.contains("hex"));
let t2: Tab = serde_json::from_str(&v).unwrap();
assert_eq!(t2.view_kind, ViewKind::Hex);
}
@@ -0,0 +1,20 @@
//! Unit tests for platform title_bar API.
use byte_draft::platform::title_bar::{detect_title_bar_host, TitleBarHost, TitleBarPlan};
#[test]
fn detect_title_bar_host_matches_cfg() {
let h = detect_title_bar_host();
#[cfg(target_os = "windows")]
assert_eq!(h, TitleBarHost::Windows);
#[cfg(target_os = "macos")]
assert_eq!(h, TitleBarHost::Macos);
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
assert_eq!(h, TitleBarHost::Linux);
}
#[test]
fn title_bar_plan_default_title() {
let p = TitleBarPlan::default();
assert_eq!(p.title, "ByteDraft");
}
@@ -0,0 +1,17 @@
//! Unit tests for editor view_kind API.
use byte_draft::editor::view_kind::ViewKind;
#[test]
fn serde_roundtrip() {
for vk in [ViewKind::Text, ViewKind::Hex] {
let s = serde_json::to_string(&vk).unwrap();
let back: ViewKind = serde_json::from_str(&s).unwrap();
assert_eq!(back, vk);
}
}
#[test]
fn default_is_text() {
assert_eq!(ViewKind::default(), ViewKind::Text);
}
@@ -0,0 +1,24 @@
//! Unit tests for editor workspace API.
use byte_draft::editor::workspace::Workspace;
use tempfile::tempdir;
#[test]
fn display_name_no_root() {
let w = Workspace::default();
assert_eq!(w.display_name(), "No folder");
}
#[test]
fn display_name_uses_final_component() {
let dir = tempdir().unwrap();
let w = Workspace {
root: Some(dir.path().to_path_buf()),
};
let expected = dir
.path()
.file_name()
.expect("tempdir has file name")
.to_string_lossy();
assert_eq!(w.display_name(), expected);
}
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "byte_draft_desktop"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[[bin]]
name = "ByteDraft"
path = "src/main.rs"
[dependencies]
byte_draft = { path = "../byte_draft" }
eframe = { version = "0.27", default-features = true, features = ["wgpu"] }
egui = "0.27"
env_logger = "0.11"
log = "0.4"
directories = "5"
# `rfd` features are target-specific so a Windows host build does not link GTK, and WSL/Linux builds do not
# pull Windows-only flags. RustRover/WSL compiles for `*-linux-gnu`: install `libgtk-3-dev` and `pkg-config`
# in WSL (`sudo apt install libgtk-3-dev pkg-config`). To produce a native `.exe` on Windows without WSL, use
# the MSVC toolchain: `rustup default stable-x86_64-pc-windows-msvc` and build outside WSL (or `--target x86_64-pc-windows-msvc`).
[target.'cfg(target_os = "windows")'.dependencies]
rfd = { version = "0.14", default-features = false, features = ["common-controls-v6"] }
[target.'cfg(target_os = "linux")'.dependencies]
rfd = { version = "0.14", default-features = false, features = ["gtk3"] }
[target.'cfg(target_os = "macos")'.dependencies]
rfd = { version = "0.14", default-features = false }
[target.'cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
rfd = { version = "0.14", default-features = false, features = ["gtk3"] }
+13
View File
@@ -0,0 +1,13 @@
//! ByteDraft — native scratch editor binary.
mod ui;
fn main() -> eframe::Result<()> {
// File dialog tracing: `RUST_LOG=byte_draft_desktop=info` or `=debug`
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("warn,byte_draft_desktop=info"),
)
.init();
ui::run()
}
+440
View File
@@ -0,0 +1,440 @@
//! Window chrome: frameless title bar with host-appropriate controls.
pub use byte_draft::{
detect_title_bar_host, TitleBarHost, TitleBarPlan, TITLE_BAR_H, WINDOWS_MENU_BAR_ROW_H,
};
use eframe::egui::{
self, Align2, Color32, Context, Id, LayerId, Order, PointerButton, Sense, Stroke, Ui,
ViewportCommand,
};
use super::resize_geometry;
const WIN_BTN_W: f32 = 46.0;
/// Invisible hit target for edge resize (wide enough for easy targeting).
pub const RESIZE_RIM_THICKNESS: f32 = 6.0;
/// Corner resize areas are larger so diagonal resize is easy to hit.
pub const RESIZE_CORNER_SIZE: f32 = 16.0;
/// Inset for main panels from the window edge (resize strip sits in the outer pixels).
pub const WINDOW_CONTENT_GUTTER: f32 = 1.0;
/// Extra space above the bottom resize strip so status / diagnostics stay readable.
pub const STATUS_PAD_ABOVE_RESIZE_STRIP: f32 = 2.0;
const MIN_INNER_SIZE: egui::Vec2 = egui::vec2(320.0, 240.0);
#[derive(Clone, Copy)]
struct ResizeAnchor {
dir: egui::ResizeDirection,
ptr0: egui::Pos2,
inner0: egui::Vec2,
/// Screen-space top-left of the **client** area at press (never `screen_rect.min`).
inner_min0: egui::Pos2,
outer_min0: egui::Pos2,
}
fn resize_anchor_id() -> Id {
Id::new("byte_draft_resize_anchor")
}
/// Thin 1px outline on the window perimeter.
pub fn draw_viewport_border(ctx: &Context) {
let p = ctx.layer_painter(LayerId::new(
Order::Foreground,
Id::new("byte_draft_viewport_border"),
));
let r = ctx.screen_rect().shrink(0.5);
p.rect_stroke(
r,
0.0,
Stroke::new(1.0, Color32::from_rgb(72, 74, 82)),
);
}
/// Drag region + window controls. `leading` is typically a hamburger; menus render on the row below when `show_menu_row`.
/// Use with `ViewportBuilder::with_decorations(false)`.
pub fn draw_custom_title_bar(
ui: &mut Ui,
ctx: &Context,
plan: &TitleBarPlan,
show_menu_row: bool,
leading: impl FnOnce(&mut Ui),
add_menus: impl FnOnce(&mut Ui),
) {
match plan.host {
TitleBarHost::Macos => title_bar_macos(ui, ctx, plan, show_menu_row, leading, add_menus),
TitleBarHost::Windows | TitleBarHost::Linux => {
title_bar_windows_like(ui, ctx, plan, show_menu_row, leading, add_menus);
}
}
}
/// Draw resize handles at window edges and corners.
///
/// - **macOS / Linux:** `ViewportCommand::BeginResize` lets the OS / winit own the drag (matches
/// [egui's custom_window_frame](https://github.com/emilk/egui/tree/master/examples/custom_window_frame) style).
/// - **Windows:** undecorated windows often ignore OS edge-drag ([winit #4186](https://github.com/rust-windowing/winit/issues/4186),
/// [egui #4345](https://github.com/emilk/egui/issues/4345)); we use manual `InnerSize` /
/// `OuterPosition` with screen-anchored west math (see `resize_geometry`).
pub fn draw_resize_handles(ctx: &Context, edge_thickness: f32, corner_size: f32, top_chrome_h: f32) {
let screen = ctx.screen_rect();
let t = edge_thickness.max(4.0);
let c = corner_size.max(t);
let y0 = top_chrome_h;
let (pointer_pos, primary_pressed, primary_down) = ctx.input(|i| {
(
i.pointer.hover_pos(),
i.pointer.primary_pressed(),
i.pointer.primary_down(),
)
});
let Some(pos) = pointer_pos else {
return;
};
let regions: &[(egui::Rect, egui::CursorIcon, egui::ResizeDirection)] = &[
(
egui::Rect::from_min_size(
egui::pos2(screen.right() - c, screen.bottom() - c),
egui::vec2(c, c),
),
egui::CursorIcon::ResizeSouthEast,
egui::ResizeDirection::SouthEast,
),
(
egui::Rect::from_min_size(
egui::pos2(screen.left(), screen.bottom() - c),
egui::vec2(c, c),
),
egui::CursorIcon::ResizeSouthWest,
egui::ResizeDirection::SouthWest,
),
(
egui::Rect::from_min_max(
egui::pos2(screen.left() + c, screen.bottom() - t),
egui::pos2(screen.right() - c, screen.bottom()),
),
egui::CursorIcon::ResizeSouth,
egui::ResizeDirection::South,
),
(
egui::Rect::from_min_max(
egui::pos2(screen.right() - t, y0),
egui::pos2(screen.right(), screen.bottom() - c),
),
egui::CursorIcon::ResizeEast,
egui::ResizeDirection::East,
),
(
egui::Rect::from_min_max(
egui::pos2(screen.left(), y0),
egui::pos2(screen.left() + t, screen.bottom() - c),
),
egui::CursorIcon::ResizeWest,
egui::ResizeDirection::West,
),
];
for (rect, cursor, _) in regions {
if rect.contains(pos) {
ctx.set_cursor_icon(*cursor);
break;
}
}
let aid = resize_anchor_id();
if primary_pressed {
#[cfg(not(target_os = "windows"))]
{
for (rect, _, direction) in regions {
if rect.contains(pos) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(*direction));
ctx.data_mut(|d| d.insert_temp::<Option<ResizeAnchor>>(aid, None));
return;
}
}
}
#[cfg(target_os = "windows")]
{
for (rect, _, direction) in regions {
if rect.contains(pos) {
let anchor = ctx.input(|i| {
let ir = i.viewport().inner_rect?;
let inner_sz = ir.size();
let inner_min0 = ir.min;
let outer_min0 = i
.viewport()
.outer_rect
.map(|r| r.min)
.unwrap_or(inner_min0);
Some(ResizeAnchor {
dir: *direction,
ptr0: pos,
inner0: inner_sz,
inner_min0,
outer_min0,
})
});
if let Some(a) = anchor {
ctx.data_mut(|d| d.insert_temp(aid, Some(a)));
}
break;
}
}
}
}
if !primary_down {
ctx.data_mut(|d| d.insert_temp::<Option<ResizeAnchor>>(aid, None));
return;
}
let Some(anchor) = ctx
.data(|d| d.get_temp::<Option<ResizeAnchor>>(aid))
.flatten()
else {
return;
};
let send_inner = |w: f32, h: f32| {
let w = w.max(MIN_INNER_SIZE.x);
let h = h.max(MIN_INNER_SIZE.y);
ctx.send_viewport_cmd(ViewportCommand::InnerSize(egui::vec2(w, h)));
ctx.request_repaint();
};
match anchor.dir {
egui::ResizeDirection::South => {
let v = resize_geometry::resize_south(anchor.inner0, anchor.ptr0, pos);
send_inner(v.x, v.y.max(MIN_INNER_SIZE.y));
}
egui::ResizeDirection::East => {
let v = resize_geometry::resize_east(anchor.inner0, anchor.ptr0, pos);
send_inner(v.x.max(MIN_INNER_SIZE.x), v.y);
}
egui::ResizeDirection::SouthEast => {
let v = resize_geometry::resize_south_east(anchor.inner0, anchor.ptr0, pos);
send_inner(
v.x.max(MIN_INNER_SIZE.x),
v.y.max(MIN_INNER_SIZE.y),
);
}
egui::ResizeDirection::West => {
let r = resize_geometry::resize_west(
anchor.inner0,
anchor.inner_min0,
anchor.outer_min0,
anchor.ptr0,
pos,
MIN_INNER_SIZE,
);
ctx.send_viewport_cmd(ViewportCommand::InnerSize(r.inner));
if let Some(o) = r.outer {
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(o));
}
ctx.request_repaint();
}
egui::ResizeDirection::SouthWest => {
let r = resize_geometry::resize_south_west(
anchor.inner0,
anchor.inner_min0,
anchor.outer_min0,
anchor.ptr0,
pos,
MIN_INNER_SIZE,
);
ctx.send_viewport_cmd(ViewportCommand::InnerSize(r.inner));
if let Some(o) = r.outer {
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(o));
}
ctx.request_repaint();
}
_ => {}
}
}
fn title_bar_macos(
ui: &mut Ui,
ctx: &Context,
plan: &TitleBarPlan,
show_menu_row: bool,
leading: impl FnOnce(&mut Ui),
add_menus: impl FnOnce(&mut Ui),
) {
ui.vertical(|ui| {
ui.set_min_height(TITLE_BAR_H);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.add_space(8.0);
traffic_lights(ui, ctx);
ui.separator();
leading(ui);
let h = ui.available_height();
let (drag_rect, drag) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(0.0), h),
Sense::drag(),
);
paint_centered_title(ui, drag_rect, &plan.title);
if drag.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
});
if show_menu_row {
egui::Frame::none()
.fill(ui.visuals().widgets.noninteractive.bg_fill)
.inner_margin(egui::Margin::symmetric(6.0, 0.0))
.show(ui, |ui| {
ui.set_min_height(WINDOWS_MENU_BAR_ROW_H);
egui::menu::bar(ui, add_menus);
});
}
});
}
fn title_bar_windows_like(
ui: &mut Ui,
ctx: &Context,
plan: &TitleBarPlan,
show_menu_row: bool,
leading: impl FnOnce(&mut Ui),
add_menus: impl FnOnce(&mut Ui),
) {
let fill = ui.visuals().widgets.noninteractive.bg_fill;
ui.vertical(|ui| {
ui.set_min_height(TITLE_BAR_H);
ui.set_max_height(TITLE_BAR_H);
let row = ui.max_rect();
ui.painter().rect_filled(row, 0.0, fill);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let h = TITLE_BAR_H;
let btn_reserve = WIN_BTN_W * 3.0;
leading(ui);
let drag_w = (ui.available_width() - btn_reserve).max(48.0);
let (drag_rect, drag) =
ui.allocate_exact_size(egui::vec2(drag_w, h), Sense::drag());
paint_centered_title(ui, drag_rect, &plan.title);
if drag.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
windows_window_buttons(ui, ctx);
});
if show_menu_row {
egui::Frame::none()
.fill(fill)
.inner_margin(egui::Margin::symmetric(4.0, 0.0))
.show(ui, |ui| {
ui.set_min_height(WINDOWS_MENU_BAR_ROW_H);
egui::menu::bar(ui, add_menus);
});
}
});
}
fn paint_centered_title(ui: &Ui, drag_rect: egui::Rect, title: &str) {
let painter = ui.painter_at(drag_rect);
let font_id = egui::TextStyle::Body.resolve(ui.style());
let color = ui.visuals().widgets.inactive.text_color();
painter.text(
drag_rect.center(),
Align2::CENTER_CENTER,
title,
font_id,
color,
);
}
fn traffic_lights(ui: &mut Ui, ctx: &Context) {
let size = 12.0;
let gap = 8.0;
let colors = [
Color32::from_rgb(255, 95, 87),
Color32::from_rgb(255, 189, 46),
Color32::from_rgb(39, 201, 63),
];
for (i, c) in colors.iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), Sense::click());
ui.painter_at(rect).circle_filled(rect.center(), size * 0.5, *c);
if resp.clicked() {
match i {
0 => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
1 => {
let maxed = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maxed));
}
2 => ctx.send_viewport_cmd(ViewportCommand::Close),
_ => {}
}
}
if i < 2 {
ui.add_space(gap);
}
}
}
fn windows_window_buttons(ui: &mut Ui, ctx: &Context) {
let h = TITLE_BAR_H;
let stroke = Stroke::new(1.0, ui.visuals().widgets.inactive.text_color());
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), Sense::click());
if resp.hovered() {
ui.painter_at(r)
.rect_filled(r, 0.0, Color32::from_white_alpha(25));
}
let c = r.center();
ui.painter_at(r).hline(c.x - 6.0..=c.x + 6.0, c.y, stroke);
if resp.clicked() {
ctx.send_viewport_cmd(ViewportCommand::Minimized(true));
}
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), Sense::click());
if resp.hovered() {
ui.painter_at(r)
.rect_filled(r, 0.0, Color32::from_white_alpha(25));
}
let maxed = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
let rr = egui::Rect::from_center_size(r.center(), egui::vec2(10.0, 10.0));
if maxed {
let back = rr.translate(egui::vec2(-2.0, 2.0));
ui.painter_at(r).rect_stroke(back, 0.0, stroke);
let front = rr.translate(egui::vec2(2.0, -2.0));
ui.painter_at(r).rect_stroke(front, 0.0, stroke);
} else {
ui.painter_at(r).rect_stroke(rr, 0.0, stroke);
}
if resp.clicked() {
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maxed));
}
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), Sense::click());
let hover_close = Color32::from_rgb(232, 17, 35);
if resp.hovered() {
ui.painter_at(r).rect_filled(r, 0.0, hover_close);
}
let c = r.center();
let arm = 5.0;
let x_color = if resp.hovered() {
Color32::WHITE
} else {
ui.visuals().widgets.inactive.text_color()
};
let xs = Stroke::new(1.0, x_color);
ui.painter_at(r).line_segment(
[c - egui::vec2(arm, arm), c + egui::vec2(arm, arm)],
xs,
);
ui.painter_at(r).line_segment(
[c - egui::vec2(arm, -arm), c + egui::vec2(arm, -arm)],
xs,
);
if resp.clicked() {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
//! Pure math for manual window-edge resize (screen-anchored west / south-west).
//!
//! Pointer positions are in **client** space. `ViewportCommand::OuterPosition` uses **screen**
//! logical coordinates. West resize must use the client area's **screen-space** `min` at press
//! time — never `screen_rect.min` (always `0,0`), which would break `OuterPosition`.
use eframe::egui::{Pos2, Vec2};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResizeResult {
pub inner: Vec2,
/// New top-left **outer** position in screen logical coordinates, if it must change.
pub outer: Option<Pos2>,
}
#[inline]
pub fn resize_south(inner0: Vec2, ptr0: Pos2, pos: Pos2) -> Vec2 {
Vec2::new(inner0.x, inner0.y + (pos.y - ptr0.y))
}
#[inline]
pub fn resize_east(inner0: Vec2, ptr0: Pos2, pos: Pos2) -> Vec2 {
Vec2::new(inner0.x + (pos.x - ptr0.x), inner0.y)
}
#[inline]
pub fn resize_south_east(inner0: Vec2, ptr0: Pos2, pos: Pos2) -> Vec2 {
Vec2::new(
inner0.x + (pos.x - ptr0.x),
inner0.y + (pos.y - ptr0.y),
)
}
/// West edge: keep the **right** edge of the client area fixed in screen space.
pub fn resize_west(
inner0: Vec2,
inner_min_screen: Pos2,
outer_min_screen: Pos2,
ptr0: Pos2,
pos: Pos2,
min_inner: Vec2,
) -> ResizeResult {
let new_w = (inner0.x - (pos.x - ptr0.x)).max(min_inner.x);
let right_screen = inner_min_screen.x + inner0.x;
let new_inner_left_screen = right_screen - new_w;
let dx_outer_inner = outer_min_screen.x - inner_min_screen.x;
let new_outer_x = new_inner_left_screen + dx_outer_inner;
ResizeResult {
inner: Vec2::new(new_w, inner0.y),
outer: Some(Pos2::new(new_outer_x, outer_min_screen.y)),
}
}
/// South-west corner: west on X, south on Y (client top edge stays at the same screen Y).
pub fn resize_south_west(
inner0: Vec2,
inner_min_screen: Pos2,
outer_min_screen: Pos2,
ptr0: Pos2,
pos: Pos2,
min_inner: Vec2,
) -> ResizeResult {
let new_w = (inner0.x - (pos.x - ptr0.x)).max(min_inner.x);
let new_h = (inner0.y + (pos.y - ptr0.y)).max(min_inner.y);
let right_screen = inner_min_screen.x + inner0.x;
let new_inner_left_screen = right_screen - new_w;
let dx_outer_inner = outer_min_screen.x - inner_min_screen.x;
let new_outer_x = new_inner_left_screen + dx_outer_inner;
ResizeResult {
inner: Vec2::new(new_w, new_h),
outer: Some(Pos2::new(new_outer_x, outer_min_screen.y)),
}
}
#[cfg(test)]
mod tests {
use super::*;
const MIN: Vec2 = Vec2::new(50.0, 50.0);
#[test]
fn west_shrink_moves_outer_right_preserves_right_edge() {
let inner0 = Vec2::new(400.0, 300.0);
let inner_min = Pos2::new(120.0, 80.0);
let outer_min = Pos2::new(120.0, 80.0);
let ptr0 = Pos2::new(0.0, 100.0);
let pos = Pos2::new(80.0, 100.0);
let r = resize_west(inner0, inner_min, outer_min, ptr0, pos, MIN);
assert!((r.inner.x - 320.0).abs() < 0.01);
assert!((r.inner.y - 300.0).abs() < 0.01);
let o = r.outer.expect("outer");
assert!((o.x - 200.0).abs() < 0.01, "outer.x={}", o.x);
assert!((o.y - 80.0).abs() < 0.01);
let right_before = inner_min.x + inner0.x;
let right_after = o.x + r.inner.x;
assert!(
(right_before - right_after).abs() < 0.01,
"right edge drift: {} vs {}",
right_before,
right_after
);
}
#[test]
fn west_decorated_outer_offset_from_inner() {
let inner0 = Vec2::new(400.0, 300.0);
let inner_min = Pos2::new(128.0, 88.0);
let outer_min = Pos2::new(120.0, 80.0);
let ptr0 = Pos2::new(0.0, 100.0);
let pos = Pos2::new(100.0, 100.0);
let r = resize_west(inner0, inner_min, outer_min, ptr0, pos, MIN);
assert!((r.inner.x - 300.0).abs() < 0.01);
let o = r.outer.expect("outer");
assert!((o.x - 220.0).abs() < 0.01, "outer.x={}", o.x);
}
#[test]
fn south_east_math() {
let inner0 = Vec2::new(200.0, 100.0);
let ptr0 = Pos2::new(190.0, 90.0);
let pos = Pos2::new(210.0, 120.0);
let v = resize_south_east(inner0, ptr0, pos);
assert!((v.x - 220.0).abs() < 0.01);
assert!((v.y - 130.0).abs() < 0.01);
}
}
+351
View File
@@ -0,0 +1,351 @@
//! Syntax highlighting for [`TextEdit`](egui::TextEdit) via `LayoutJob`.
use std::sync::Arc;
use byte_draft::LanguageId;
use eframe::egui::text::{LayoutJob, TextFormat};
use eframe::egui::{Color32, FontId, TextStyle, Ui};
fn mono(ui: &Ui) -> FontId {
TextStyle::Monospace.resolve(ui.style())
}
fn push(job: &mut LayoutJob, text: &str, color: Color32, font_id: &FontId) {
if text.is_empty() {
return;
}
job.append(text, 0.0, TextFormat::simple(font_id.clone(), color));
}
/// Paint `full_text[abs_range]` using `base_color`, or `find_color` where byte offsets intersect `find_ranges`.
fn push_slice_find(
job: &mut LayoutJob,
full_text: &str,
abs_range: std::ops::Range<usize>,
base_color: Color32,
font_id: &FontId,
find_ranges: &[(usize, usize)],
find_color: Color32,
) {
let start = abs_range.start.min(full_text.len());
let end = abs_range.end.min(full_text.len());
if start >= end {
return;
}
if find_ranges.is_empty() {
push(job, &full_text[start..end], base_color, font_id);
return;
}
let mut i = start;
while i < end {
let in_match = find_ranges
.iter()
.any(|&(s, e)| i < e && s < end && i >= s);
let next = if in_match {
find_ranges
.iter()
.filter(|&&(s, e)| i >= s && i < e)
.map(|(_, e)| *e)
.min()
.unwrap_or(end)
} else {
find_ranges
.iter()
.filter_map(|&(s, _)| if s > i && s < end { Some(s) } else { None })
.min()
.unwrap_or(end)
};
let next = next.min(end);
let color = if in_match { find_color } else { base_color };
push(job, &full_text[i..next], color, font_id);
i = next;
}
}
const FIND_TEXT_COLOR: Color32 = Color32::from_rgb(255, 220, 80);
fn layout_plain(
ui: &Ui,
text: &str,
wrap_width: f32,
color: Color32,
find_ranges: &[(usize, usize)],
) -> Arc<eframe::egui::Galley> {
let font_id = mono(ui);
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
push_slice_find(
&mut job,
text,
0..text.len(),
color,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
ui.fonts(|f| f.layout_job(job))
}
fn layout_json(
ui: &Ui,
text: &str,
wrap_width: f32,
find_ranges: &[(usize, usize)],
) -> Arc<eframe::egui::Galley> {
let font_id = mono(ui);
let default_c = Color32::from_rgb(212, 212, 212);
let str_c = Color32::from_rgb(206, 145, 120);
let num_c = Color32::from_rgb(181, 206, 168);
let kw_c = Color32::from_rgb(86, 156, 214);
let punct_c = Color32::from_rgb(128, 128, 128);
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
let mut i = 0usize;
let b = text.as_bytes();
while i < b.len() {
let c = b[i];
if (c as char).is_whitespace() {
let start = i;
i += 1;
while i < b.len() && (b[i] as char).is_whitespace() {
i += 1;
}
push_slice_find(
&mut job,
text,
start..i,
default_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
continue;
}
match c {
b'{' | b'}' | b'[' | b']' | b':' | b',' => {
push_slice_find(
&mut job,
text,
i..i + 1,
punct_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
i += 1;
}
b'"' => {
let start = i;
i += 1;
while i < b.len() {
if b[i] == b'\\' && i + 1 < b.len() {
i += 2;
continue;
}
if b[i] == b'"' {
i += 1;
break;
}
i += 1;
}
let mut color = str_c;
let slice = &text[start..i];
if slice.starts_with('"') && slice.ends_with('"') {
let mut j = i;
while j < b.len() && (b[j] as char).is_whitespace() {
j += 1;
}
if j < b.len() && b[j] == b':' {
color = Color32::from_rgb(156, 220, 254);
}
}
push_slice_find(
&mut job,
text,
start..i,
color,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
}
b'-' | b'0'..=b'9' => {
let start = i;
if c == b'-' {
i += 1;
}
while i < b.len() {
match b[i] {
b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-' => i += 1,
_ => break,
}
}
push_slice_find(
&mut job,
text,
start..i,
num_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
}
b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
let start = i;
while i < b.len() {
match b[i] {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' => i += 1,
_ => break,
}
}
let word = &text[start..i];
let color = match word {
"true" | "false" | "null" => kw_c,
_ => default_c,
};
push_slice_find(
&mut job,
text,
start..i,
color,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
}
_ => {
let ch = text[i..].chars().next().unwrap();
let len = ch.len_utf8();
push_slice_find(
&mut job,
text,
i..i + len,
default_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
i += len;
}
}
}
ui.fonts(|f| f.layout_job(job))
}
/// Minimal XML-ish highlighting: `<`…`>` and `<!-- -->`.
fn layout_xml(
ui: &Ui,
text: &str,
wrap_width: f32,
find_ranges: &[(usize, usize)],
) -> Arc<eframe::egui::Galley> {
let font_id = mono(ui);
let default_c = Color32::from_rgb(212, 212, 212);
let bracket_c = Color32::from_rgb(128, 128, 128);
let tag_c = Color32::from_rgb(86, 156, 214);
let comment_c = Color32::from_rgb(106, 153, 85);
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
let mut i = 0usize;
while i < text.len() {
if text[i..].starts_with("<!--") {
if let Some(rel) = text[i..].find("-->") {
let end = i + rel + 3;
push_slice_find(
&mut job,
text,
i..end,
comment_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
i = end;
continue;
}
}
if text.as_bytes().get(i) == Some(&b'<') {
if let Some(rel) = text[i..].find('>') {
let end = i + rel + 1;
push_slice_find(
&mut job,
text,
i..i + 1,
bracket_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
if end > i + 1 {
push_slice_find(
&mut job,
text,
i + 1..end - 1,
tag_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
}
push_slice_find(
&mut job,
text,
end - 1..end,
bracket_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
i = end;
continue;
}
}
let start = i;
i += 1;
while i < text.len() {
if text[i..].starts_with("<!--") {
break;
}
if text.as_bytes().get(i) == Some(&b'<') {
break;
}
i += 1;
}
push_slice_find(
&mut job,
text,
start..i,
default_c,
&font_id,
find_ranges,
FIND_TEXT_COLOR,
);
}
ui.fonts(|f| f.layout_job(job))
}
#[must_use]
pub fn layout_highlighted(
ui: &Ui,
text: &str,
lang: LanguageId,
wrap_width: f32,
find_ranges: &[(usize, usize)],
) -> Arc<eframe::egui::Galley> {
let default = ui.visuals().widgets.inactive.text_color();
match lang {
LanguageId::Json => layout_json(ui, text, wrap_width, find_ranges),
LanguageId::Xml => layout_xml(ui, text, wrap_width, find_ranges),
LanguageId::Plain | LanguageId::Yaml | LanguageId::Toml | LanguageId::Unknown => {
layout_plain(ui, text, wrap_width, default, find_ranges)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
# cargo-deny: advisories (RustSec), license policy, duplicate crate detection.
# https://embarkstudios.github.io/cargo-deny/
#
# Install: cargo install cargo-deny
# Check: cargo deny check
[advisories]
version = 2
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/RustSec/advisory-db"]
# Only fail on advisories for your own crates; transitive unmaintained is common in GUI stacks.
unmaintained = "workspace"
[licenses]
version = 2
confidence-threshold = 0.8
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"ISC",
"Zlib",
"Unicode-3.0",
"CC0-1.0",
"Unlicense",
"MPL-2.0",
"LGPL-2.1",
"LGPL-3.0",
"OpenSSL",
"OFL-1.1",
"LicenseRef-UFL-1.0",
]
[bans]
multiple-versions = "warn"
[sources]
unknown-registry = "deny"
unknown-git = "warn"
+754
View File
@@ -0,0 +1,754 @@
<#
.SYNOPSIS
Run security checks for ByteDraft (Rust SCA/SAST helpers + optional Podman scanners).
.DESCRIPTION
Reads security/security.config.yaml (flat key: value).
Rust: cargo deny, cargo audit, clippy, fmt, geiger, outdated, udeps.
Optional: Semgrep, Trivy, gitleaks, OSV-Scanner, Qryon (native and/or Podman per config).
Qryon CLI is shipped as the crates.io package rma-cli (binary name: qryon); see https://github.com/bumahkib7/qryon
Podman steps mount the repo at /src (read-only) and reports at /out (read-write).
.PARAMETER ConfigPath
Override path to security.config.yaml (default: repo security/security.config.yaml).
.PARAMETER SkipPodman
Do not run container-based tools even if enabled in config.
.PARAMETER SkipClippy
Skip cargo clippy (useful when MSVC link.exe / full toolchain is unavailable).
.PARAMETER SkipNative
Skip all native cargo tools (useful for container-only scanning).
.EXAMPLE
.\scripts\security-scan.ps1
.EXAMPLE
.\scripts\security-scan.ps1 -SkipPodman # Native tools only
#>
[CmdletBinding()]
param(
[string]$ConfigPath = "",
[switch]$SkipPodman,
[switch]$SkipClippy,
[switch]$SkipNative
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Get-RepoRoot {
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
Resolve-Path (Join-Path $here "..")
}
function Read-FlatSecurityConfig {
param([string]$Path)
$cfg = @{}
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
$t = $line.Trim()
if ($t -eq "" -or $t.StartsWith("#")) { continue }
$parts = $t -split ":", 2
if ($parts.Count -lt 2) { continue }
$key = $parts[0].Trim()
$val = $parts[1].Trim()
if ($val -eq "true") { $cfg[$key] = $true }
elseif ($val -eq "false") { $cfg[$key] = $false }
elseif ($val -match "^\d+$") { $cfg[$key] = [int]$val }
else { $cfg[$key] = $val }
}
$cfg
}
function Test-CommandExists {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Test-PodmanAvailable {
return (-not $SkipPodman) -and (Test-CommandExists "podman")
}
# Podman writes progress to stderr. On Windows PowerShell 5.1, native stderr becomes ErrorRecords; redirecting 2>&1
# still yields ErrorRecord objects, which Format-Table as scary red "NativeCommandError" even when the command succeeded.
# Normalize to plain strings before Write-Host / log files.
function Remove-AnsiEscape {
param([string]$Text)
if ([string]::IsNullOrEmpty($Text)) { return $Text }
return [regex]::Replace($Text, "\x1B\[[0-9;]*[a-zA-Z]", "")
}
# PowerShell 5.1 Set-Content -Encoding UTF8 is inconsistent for tool output; use UTF-8 no BOM for logs.
function Write-ReportTextLines {
param(
[Parameter(Mandatory)][string]$LiteralPath,
[string[]]$Lines
)
$enc = New-Object System.Text.UTF8Encoding $false
$arr = [System.Collections.ArrayList]@()
foreach ($x in $Lines) {
if ($null -eq $x) { continue }
[void]$arr.Add((Remove-AnsiEscape "$x"))
}
[System.IO.File]::WriteAllLines($LiteralPath, [string[]]$arr.ToArray(), $enc)
}
function Convert-PodmanOutputLine {
param($Object)
if ($null -eq $Object) { return $null }
if ($Object -is [System.Management.Automation.ErrorRecord]) {
$to = $Object.TargetObject
if ($null -ne $to -and "$to".Trim() -ne "") { return "$to".TrimEnd() }
$em = $Object.Exception.Message
if ($em) { return $em.Trim() }
return ($Object.ToString())
}
$s = "$Object"
if ($s) { return $s.TrimEnd() }
return $null
}
function Write-PodmanOutputLines {
param($Chunks)
if ($null -eq $Chunks) { return }
if ($Chunks -isnot [System.Array]) { $Chunks = @($Chunks) }
foreach ($c in $Chunks) {
$line = Convert-PodmanOutputLine $c
if ($line) { Write-Host (Remove-AnsiEscape $line) }
}
}
# Temporarily relax ErrorAction for native exes; stderr is handled via Convert-PodmanOutputLine above.
function Invoke-PodmanCommand {
param([scriptblock]$Command)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $Command
} finally {
$ErrorActionPreference = $prevEap
}
}
function Invoke-PodmanExe {
param([Parameter(Mandatory)][string[]]$Arguments)
Invoke-PodmanCommand {
$chunks = & podman @Arguments 2>&1
Write-PodmanOutputLines $chunks
}
}
function Invoke-PodmanRun {
param([string[]]$PodmanArgs)
if (-not (Test-PodmanAvailable)) {
return $false
}
Invoke-PodmanExe $PodmanArgs
return $true
}
# Pull failures from `podman run` often do not use exit code 125; pull explicitly and try each candidate.
function Resolve-PodmanImage {
param([string[]]$Candidates)
foreach ($img in ($Candidates | Where-Object { $_ } | Select-Object -Unique)) {
Write-Host "podman: pulling $img ..." -ForegroundColor DarkGray
Invoke-PodmanExe @("pull", $img)
if ($LASTEXITCODE -eq 0) {
return $img
}
Write-Host "podman: pull failed for $img (exit $LASTEXITCODE); trying next candidate if any..." -ForegroundColor Yellow
}
return $null
}
$RepoRoot = Get-RepoRoot
Set-Location $RepoRoot
if (-not $ConfigPath) {
$ConfigPath = Join-Path $RepoRoot "security\security.config.yaml"
}
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "Config not found: $ConfigPath"
}
$C = Read-FlatSecurityConfig -Path $ConfigPath
$reportsRel = $C["reports_dir"]
if (-not $reportsRel) { $reportsRel = "security/reports" }
$reportsDir = Join-Path $RepoRoot $reportsRel
New-Item -ItemType Directory -Force -Path $reportsDir | Out-Null
$failed = $false
$scanNotes = New-Object System.Collections.ArrayList
function Add-ScanNote {
param([string]$Message)
[void]$scanNotes.Add($Message)
}
function Write-ReportInventory {
Write-Host ""
Write-Host "=== Report inventory ===" -ForegroundColor Cyan
function Write-InvRow {
param([string]$Label, [string]$RelPath, [int]$MinBytes)
$p = Join-Path $reportsDir $RelPath
if (-not (Test-Path -LiteralPath $p)) {
Write-Host " [missing] $Label ($RelPath)" -ForegroundColor Yellow
return
}
$len = (Get-Item -LiteralPath $p).Length
$ok = ($len -ge $MinBytes)
$flag = if ($ok) { "ok" } else { "small" }
Write-Host " [$flag] $Label $len bytes $RelPath" -ForegroundColor $(if ($ok) { "DarkGray" } else { "Yellow" })
}
if ($C["semgrep_enabled"]) { Write-InvRow "Semgrep" "semgrep-report.json" 40 }
if ($C["trivy_enabled"]) { Write-InvRow "Trivy" "trivy-fs.txt" 15 }
if ($C["gitleaks_enabled"]) {
Write-InvRow "gitleaks JSON" "gitleaks-report.json" 2
Write-InvRow "gitleaks log" "gitleaks-console.log" 1
}
if ($C["osv_scanner_enabled"]) { Write-InvRow "OSV-Scanner" "osv-scanner-report.json" 30 }
if ($C["qryon_enabled"]) {
$qf = $C["qryon_format"]
if (-not $qf) { $qf = "json" }
$qryonRel = switch ("$qf") {
"json" { "qryon-report.json" }
"markdown" { "qryon-report.md" }
"sarif" { "qryon-report.sarif" }
"html" { "qryon-report.html" }
default { "qryon-report.txt" }
}
$qRep = Join-Path $reportsDir $qryonRel
$qSk = Join-Path $reportsDir "qryon-skipped.txt"
if (Test-Path -LiteralPath $qRep) {
Write-InvRow "Qryon" $qryonRel 40
} elseif (Test-Path -LiteralPath $qSk) {
Write-Host " [skip] Qryon (see qryon-skipped.txt)" -ForegroundColor DarkYellow
} else {
Write-Host " [missing] Qryon (no $qryonRel or qryon-skipped.txt)" -ForegroundColor Yellow
}
}
}
function Run-Step {
param([string]$Name, [scriptblock]$Action)
Write-Host ""
Write-Host "=== $Name ===" -ForegroundColor Cyan
try {
& $Action
} catch {
Write-Host "FAILED: $_" -ForegroundColor Red
$script:failed = $true
}
}
# --- cargo deny ---
if ($C["cargo_deny_enabled"] -and -not $SkipNative) {
Run-Step "cargo deny" {
if (-not (Test-CommandExists "cargo-deny")) {
throw "cargo-deny not on PATH. Install: cargo install cargo-deny"
}
& cargo-deny check
if ($LASTEXITCODE -ne 0) { throw "cargo deny exited $LASTEXITCODE" }
}
}
# --- cargo audit ---
if ($C["cargo_audit_enabled"] -and -not $SkipNative) {
Run-Step "cargo audit" {
if (-not (Test-CommandExists "cargo-audit")) {
throw "cargo-audit not on PATH. Install: cargo install cargo-audit"
}
& cargo audit
if ($LASTEXITCODE -ne 0) { throw "cargo audit exited $LASTEXITCODE" }
}
}
# --- clippy ---
if ($C["cargo_clippy_enabled"] -and -not $SkipClippy -and -not $SkipNative) {
Run-Step "cargo clippy" {
$extra = @()
if ($C["cargo_clippy_deny_warnings"]) {
$extra = @("--", "-D", "warnings")
}
& cargo clippy --all-targets --all-features @extra
if ($LASTEXITCODE -ne 0) { throw "clippy exited $LASTEXITCODE" }
}
}
# --- cargo fmt --check (readability) ---
if ($C["cargo_fmt_check_enabled"] -and -not $SkipNative) {
Run-Step "cargo fmt --check" {
& cargo fmt --check
if ($LASTEXITCODE -ne 0) { throw "cargo fmt found formatting issues (run 'cargo fmt' to fix)" }
}
}
# --- cargo-geiger (unsafe code detection) ---
if ($C["cargo_geiger_enabled"] -and -not $SkipNative) {
Run-Step "cargo geiger" {
if (-not (Test-CommandExists "cargo-geiger")) {
Write-Host "cargo-geiger not installed. Install: cargo install cargo-geiger --locked" -ForegroundColor Yellow
} else {
$report = Join-Path $reportsDir "geiger-report.txt"
& cargo geiger 2>&1 | Tee-Object -FilePath $report
Write-Host "Geiger report saved to $report"
}
}
}
# --- cargo-outdated (dependency freshness) ---
if ($C["cargo_outdated_enabled"] -and -not $SkipNative) {
Run-Step "cargo outdated" {
if (-not (Test-CommandExists "cargo-outdated")) {
Write-Host "cargo-outdated not installed. Install: cargo install cargo-outdated" -ForegroundColor Yellow
} else {
$report = Join-Path $reportsDir "outdated-report.txt"
& cargo outdated 2>&1 | Tee-Object -FilePath $report
Write-Host "Outdated report saved to $report"
}
}
}
# --- cargo-udeps (unused dependencies, requires nightly) ---
if ($C["cargo_udeps_enabled"] -and -not $SkipNative) {
Run-Step "cargo udeps" {
if (-not (Test-CommandExists "cargo-udeps")) {
Write-Host "cargo-udeps not installed. Install: cargo install cargo-udeps --locked" -ForegroundColor Yellow
} else {
& cargo +nightly udeps --all-targets
if ($LASTEXITCODE -ne 0) {
Write-Host "cargo udeps requires nightly toolchain: rustup install nightly" -ForegroundColor Yellow
}
}
}
}
# Podman volumes: :Z is SELinux (Linux); omit on Windows/WSL if mounts fail—use :ro only.
# /out is the same folder as $reportsDir on the host so container exits always leave artifacts to open locally.
$mountSrc = "${RepoRoot}:/src:ro"
$mountOut = "${reportsDir}:/out:rw"
if ((Test-PodmanAvailable) -and ($C["semgrep_enabled"] -or $C["trivy_enabled"] -or $C["gitleaks_enabled"] -or $C["osv_scanner_enabled"] -or $C["qryon_enabled"])) {
Write-Host "Podman report output mount: $reportsDir -> /out" -ForegroundColor DarkCyan
}
# --- Semgrep ---
if ($C["semgrep_enabled"]) {
Run-Step "semgrep" {
$cfg = $C["semgrep_config"]
if (-not $cfg) { $cfg = "p/rust" }
$image = $C["semgrep_image"]
if (-not $image) { $image = "docker.io/semgrep/semgrep:latest" }
$ran = $false
if ($C["semgrep_use_podman"] -and (Test-PodmanAvailable)) {
$ran = Invoke-PodmanRun -PodmanArgs @(
"run", "--rm",
"-v", $mountSrc,
"-v", $mountOut,
$image,
"semgrep", "--error", "--config", $cfg, "--json", "--json-output", "/out/semgrep-report.json", "/src"
)
if ($ran -and $LASTEXITCODE -ne 0) { throw "semgrep (podman) exited $LASTEXITCODE" }
if ($ran) {
$semPath = Join-Path $reportsDir "semgrep-report.json"
Write-Host "semgrep: JSON report -> $semPath" -ForegroundColor DarkGray
if (Test-Path -LiteralPath $semPath) {
try {
$sj = Get-Content -LiteralPath $semPath -Raw -Encoding UTF8 | ConvertFrom-Json
$n = @($sj.results).Count
Write-Host "semgrep: $n finding(s) in JSON (0 = clean)." -ForegroundColor DarkGray
} catch {
Write-Host "semgrep: could not parse JSON summary." -ForegroundColor Yellow
}
}
}
}
if (-not $ran) {
if (Test-CommandExists "semgrep") {
$semOut = Join-Path $reportsDir "semgrep-report.json"
& semgrep --error --config $cfg --json --json-output $semOut $RepoRoot
if ($LASTEXITCODE -ne 0) { throw "semgrep exited $LASTEXITCODE" }
Write-Host "semgrep: JSON report -> $semOut" -ForegroundColor DarkGray
try {
$sj = Get-Content -LiteralPath $semOut -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host "semgrep: $(@($sj.results).Count) finding(s) in JSON (0 = clean)." -ForegroundColor DarkGray
} catch { }
} else {
throw "Semgrep not available. Install locally or set semgrep_use_podman with Podman installed."
}
}
}
}
# --- Trivy filesystem ---
if ($C["trivy_enabled"]) {
Run-Step "trivy fs" {
$sev = $C["trivy_severity"]
if (-not $sev) { $sev = "HIGH,CRITICAL" }
$trivyExitWhenFound = if ($C["trivy_fail_on_findings"] -eq $true) { 1 } else { 0 }
$primary = $C["trivy_image"]
if (-not $primary) { $primary = "docker.io/aquasec/trivy:0.69.3" }
$fallback = $C["trivy_fallback_image"]
if (-not $fallback) { $fallback = "ghcr.io/aquasecurity/trivy:0.69.3" }
$report = Join-Path $reportsDir "trivy-fs.txt"
$trivySkipRaw = $C["trivy_skip_dirs"]
if (-not $trivySkipRaw) { $trivySkipRaw = "target" }
$trivySkipDirs = [string]$trivySkipRaw -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ }
$trivySkipArgs = @()
foreach ($d in $trivySkipDirs) { $trivySkipArgs += @("--skip-dirs", $d) }
$ran = $false
if ($C["trivy_use_podman"] -and (Test-PodmanAvailable)) {
$candidates = @($primary, $fallback)
$chosen = Resolve-PodmanImage -Candidates $candidates
if (-not $chosen) {
throw "trivy: could not pull any image. Tried: $($candidates -join ', '). Log in: podman login docker.io`n or: podman login ghcr.io"
}
Write-Host "trivy: scanning with $chosen (skip-dirs: $($trivySkipDirs -join ', '))" -ForegroundColor DarkGray
$trivyPodArgs = @(
"run", "--rm", "-v", $mountSrc, "-v", $mountOut, $chosen, "fs"
) + $trivySkipArgs + @(
"--severity", $sev, "--scanners", "vuln", "--exit-code", "$trivyExitWhenFound",
"--format", "table", "--output", "/out/trivy-fs.txt", "/src"
)
Invoke-PodmanExe $trivyPodArgs
if ($LASTEXITCODE -ne 0) { throw "trivy (podman) exited $LASTEXITCODE" }
$ran = $true
Write-Host "trivy: report -> $report" -ForegroundColor DarkGray
}
if (-not $ran) {
if (Test-CommandExists "trivy") {
Invoke-PodmanCommand {
$tc = & trivy fs @trivySkipArgs --severity $sev --scanners vuln --exit-code $trivyExitWhenFound --format table --output $report $RepoRoot 2>&1
Write-PodmanOutputLines $tc
}
if ($LASTEXITCODE -ne 0) { throw "trivy exited $LASTEXITCODE" }
Write-Host "trivy: report -> $report" -ForegroundColor DarkGray
} else {
throw "Trivy not available. Install locally or set trivy_use_podman with Podman installed."
}
}
}
}
# --- gitleaks ---
if ($C["gitleaks_enabled"]) {
Run-Step "gitleaks" {
$image = $C["gitleaks_image"]
if (-not $image) { $image = "ghcr.io/gitleaks/gitleaks:v8.30.1" }
$ran = $false
# Podman path first: do not run host `git` here (avoids noise and ordering issues). Official GHCR image + --no-git on /src.
if ($C["gitleaks_use_podman"] -and (Test-PodmanAvailable)) {
$chosen = Resolve-PodmanImage -Candidates @($image)
if (-not $chosen) {
throw "gitleaks: could not pull $image. Try: podman login ghcr.io"
}
Write-Host "gitleaks: detect --no-git --source /src (Podman)" -ForegroundColor DarkGray
$inner = @(
"detect", "--no-git", "--source", "/src", "--verbose",
"--report-format", "json", "--report-path", "/out/gitleaks-report.json"
)
$gLog = Join-Path $reportsDir "gitleaks-console.log"
$gJson = Join-Path $reportsDir "gitleaks-report.json"
$glPodArgs = @("run", "--rm", "-e", "NO_COLOR=1", "-v", $mountSrc, "-v", $mountOut, $chosen) + $inner
Invoke-PodmanCommand {
$chunks = & podman @glPodArgs 2>&1
$lines = @($chunks | ForEach-Object { Convert-PodmanOutputLine $_ } | Where-Object { $_ })
if ($lines.Count -gt 0) {
Write-ReportTextLines -LiteralPath $gLog -Lines $lines
} else {
Write-ReportTextLines -LiteralPath $gLog -Lines @("(no console output)")
}
foreach ($ln in $lines) {
if ($ln) { Write-Host (Remove-AnsiEscape $ln) }
}
}
if ($LASTEXITCODE -ne 0) { throw "gitleaks (podman) exited $LASTEXITCODE" }
$ran = $true
Write-Host "gitleaks: JSON report -> $gJson" -ForegroundColor DarkGray
Write-Host "gitleaks: plain-text log -> $gLog (no secrets; same lines as above)" -ForegroundColor DarkGray
if (Test-Path -LiteralPath $gJson) {
try {
$graw = Get-Content -LiteralPath $gJson -Raw -Encoding UTF8
$gj = $graw | ConvertFrom-Json
if ($null -eq $gj) { $gc = 0 }
elseif ($gj -is [System.Array]) { $gc = $gj.Count }
else { $gc = 1 }
Write-Host "gitleaks: $gc leak finding(s) in JSON ([] = none)." -ForegroundColor DarkGray
} catch {
Write-Host "gitleaks: could not parse JSON report." -ForegroundColor Yellow
}
}
}
if (-not $ran) {
$noGitNative = $false
if ($C["gitleaks_no_git"]) {
$noGitNative = $true
} elseif (Test-Path (Join-Path $RepoRoot ".git")) {
Push-Location $RepoRoot
try {
$null = git rev-parse --verify HEAD 2>$null
if ($LASTEXITCODE -ne 0) { $noGitNative = $true }
} finally {
Pop-Location
}
} else {
$noGitNative = $true
}
if ($noGitNative) {
Write-Host "gitleaks: native scan with --no-git" -ForegroundColor DarkYellow
}
if (Test-CommandExists "gitleaks") {
$gOut = Join-Path $reportsDir "gitleaks-report.json"
$local = @(
"detect", "--source", $RepoRoot, "--verbose",
"--report-format", "json", "--report-path", $gOut
)
if ($noGitNative) { $local += "--no-git" }
& gitleaks @local
if ($LASTEXITCODE -ne 0) { throw "gitleaks exited $LASTEXITCODE" }
Write-Host "gitleaks: JSON report -> $gOut" -ForegroundColor DarkGray
} else {
throw "gitleaks not available. Install locally or set gitleaks_use_podman with Podman installed."
}
}
}
}
# --- OSV-Scanner (V2 CLI: `scan -r`; image is ghcr.io/google/osv-scanner) ---
if ($C["osv_scanner_enabled"]) {
Run-Step "osv-scanner" {
$image = $C["osv_scanner_image"]
if (-not $image) { $image = "ghcr.io/google/osv-scanner:stable" }
$ran = $false
$osvReport = Join-Path $reportsDir "osv-scanner-report.json"
if ($C["osv_scanner_use_podman"] -and (Test-PodmanAvailable)) {
$ran = Invoke-PodmanRun -PodmanArgs @(
"run", "--rm",
"-v", $mountSrc,
"-v", $mountOut,
$image,
"scan", "-r", "/src",
"--format", "json",
"--output-file", "/out/osv-scanner-report.json"
)
if ($ran) {
$oec = $LASTEXITCODE
# Exit 1 = vulnerabilities or license findings (not a crash). See https://google.github.io/osv-scanner/output/
if ($oec -eq 1 -and $C["osv_scanner_fail_on_findings"] -ne $true) {
Write-Host "osv-scanner: exit 1 means findings were reported; scan completed. Set osv_scanner_fail_on_findings: true to fail the script." -ForegroundColor Yellow
} elseif ($oec -ne 0) {
throw "osv-scanner (podman) exited $oec"
}
Write-Host "osv-scanner: JSON report -> $osvReport" -ForegroundColor DarkGray
if (Test-Path -LiteralPath $osvReport) {
try {
$oj = Get-Content -LiteralPath $osvReport -Raw -Encoding UTF8 | ConvertFrom-Json
$src = @($oj.results).Count
Write-Host "osv-scanner: $src top-level result block(s) in JSON (see file for package/vuln detail)." -ForegroundColor DarkGray
} catch { }
}
}
}
if (-not $ran) {
if (Test-CommandExists "osv-scanner") {
Invoke-PodmanCommand {
$oc = & osv-scanner scan -r $RepoRoot --format json --output-file $osvReport 2>&1
Write-PodmanOutputLines $oc
}
$oec = $LASTEXITCODE
if ($oec -eq 1 -and $C["osv_scanner_fail_on_findings"] -ne $true) {
Write-Host "osv-scanner: exit 1 = findings reported (native run)." -ForegroundColor Yellow
} elseif ($oec -ne 0) {
throw "osv-scanner exited $oec"
}
Write-Host "osv-scanner: JSON report -> $osvReport" -ForegroundColor DarkGray
if (Test-Path -LiteralPath $osvReport) {
try {
$oj = Get-Content -LiteralPath $osvReport -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host "osv-scanner: $(@($oj.results).Count) source block(s) in JSON." -ForegroundColor DarkGray
} catch { }
}
} else {
throw "osv-scanner not on PATH. Install locally or set osv_scanner_use_podman with Podman installed."
}
}
}
}
# --- Qryon (SAST; https://github.com/bumahkib7/qryon — install: cargo install rma-cli, binary: qryon) ---
# Uses current CLI: qryon scan [PATH] --no-color -f <format> -o <file> [-p profile]. NO_COLOR=1 alone breaks clap; we clear env and pass --no-color.
# Upstream GHCR images have had glibc/base mismatches; verify in-container before scan when using Podman.
if ($C["qryon_enabled"]) {
Run-Step "qryon" {
$primary = $C["qryon_image"]
if (-not $primary) { $primary = "ghcr.io/bumahkib7/qryon:0.20.1" }
$candidates = @(
$primary,
"ghcr.io/bumahkib7/qryon:latest"
)
$qFmt = $C["qryon_format"]
if (-not $qFmt) { $qFmt = "json" }
$qProf = $C["qryon_profile"]
if (-not $qProf) { $qProf = "balanced" }
$qryonRel = switch ("$qFmt") {
"json" { "qryon-report.json" }
"markdown" { "qryon-report.md" }
"sarif" { "qryon-report.sarif" }
"html" { "qryon-report.html" }
default { "qryon-report.txt" }
}
$report = Join-Path $reportsDir $qryonRel
$outInContainer = "/out/$qryonRel"
$ran = $false
$qryonStrict = ($C["qryon_fail_on_findings"] -eq $true)
if ($C["qryon_use_podman"] -and (Test-PodmanAvailable)) {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$chosen = $null
foreach ($img in ($candidates | Where-Object { $_ } | Select-Object -Unique)) {
Write-Host "qryon: pulling $img ..." -ForegroundColor DarkGray
Invoke-PodmanExe @("pull", $img)
if ($LASTEXITCODE -ne 0) {
Write-Host "qryon: pull failed for $img (exit $LASTEXITCODE); trying next candidate if any..." -ForegroundColor Yellow
continue
}
Write-Host "qryon: verifying binary starts in $img ..." -ForegroundColor DarkGray
$verifyChunks = & podman run --rm $img --version 2>&1
Write-PodmanOutputLines $verifyChunks
$vec = $LASTEXITCODE
if ($vec -ne 0) {
Write-Host "qryon: image pulled but binary does not run in-container (exit $vec). Often GLIBC_* mismatch between binary and base image. Trying next candidate if any." -ForegroundColor Yellow
continue
}
$chosen = $img
break
}
if ($chosen) {
Write-Host "qryon: scanning with $chosen (-f $qFmt -p $qProf -> $qryonRel)" -ForegroundColor DarkGray
Invoke-PodmanCommand {
$qc = & podman run --rm -v $mountSrc -v $mountOut $chosen scan /src --no-color -p $qProf -f $qFmt -o $outInContainer 2>&1
Write-PodmanOutputLines $qc
}
$qec = $LASTEXITCODE
if ($qec -eq 0) {
$ran = $true
} elseif ($qec -eq 1 -and -not $qryonStrict) {
Write-Host "qryon: exit 1 likely means findings; continuing. Set qryon_fail_on_findings: true to fail the script." -ForegroundColor Yellow
$ran = $true
} else {
throw "qryon (podman) exited $qec (image: $chosen)"
}
if (-not (Test-Path -LiteralPath $report)) {
throw "qryon (podman) did not write report at $report"
}
Remove-Item (Join-Path $reportsDir "qryon-skipped.txt") -ErrorAction SilentlyContinue
Write-Host "qryon: report -> $report" -ForegroundColor DarkGray
if ("$qFmt" -eq "json") {
try {
$qj = Get-Content -LiteralPath $report -Raw -Encoding UTF8 | ConvertFrom-Json
$qFiles = @($qj.results).Count
$qFind = 0
foreach ($row in @($qj.results)) {
if ($row.findings) { $qFind += @($row.findings).Count }
}
Write-Host "qryon: $qFiles file row(s) in JSON; $qFind security finding(s) across results[].findings." -ForegroundColor DarkGray
} catch { }
}
} else {
Write-Host "qryon: no usable container image (pull or in-container start failed). Tried: $(($candidates | Select-Object -Unique) -join ', ')" -ForegroundColor Yellow
Write-Host "qryon: use native CLI: cargo install rma-cli (or set qryon_use_podman: false)" -ForegroundColor Yellow
}
} finally {
$ErrorActionPreference = $prevEap
}
}
if (-not $ran) {
if (Test-CommandExists "qryon") {
# clap maps NO_COLOR to --no-color; value "1" is invalid. Clear for this invocation; use explicit --no-color.
$noColorSaved = $env:NO_COLOR
Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue
try {
Invoke-PodmanCommand {
$chunks = & qryon scan $RepoRoot --no-color -p $qProf -f $qFmt -o $report 2>&1
Write-PodmanOutputLines $chunks
}
} finally {
if ($null -ne $noColorSaved) { $env:NO_COLOR = $noColorSaved }
}
$qec = $LASTEXITCODE
if ($qec -eq 1 -and -not $qryonStrict) {
Write-Host "qryon: exit 1 likely means findings (native run)." -ForegroundColor Yellow
} elseif ($qec -ne 0) {
throw "qryon exited $qec"
}
if (-not (Test-Path -LiteralPath $report)) {
throw "qryon did not write report at $report"
}
Remove-Item (Join-Path $reportsDir "qryon-skipped.txt") -ErrorAction SilentlyContinue
Write-Host "qryon: report -> $report" -ForegroundColor DarkGray
if ("$qFmt" -eq "json") {
try {
$qj = Get-Content -LiteralPath $report -Raw -Encoding UTF8 | ConvertFrom-Json
$qFiles = @($qj.results).Count
$qFind = 0
foreach ($row in @($qj.results)) {
if ($row.findings) { $qFind += @($row.findings).Count }
}
Write-Host "qryon: $qFiles file row(s) in JSON; $qFind security finding(s) across results[].findings." -ForegroundColor DarkGray
} catch {
Write-Host "qryon: could not parse JSON summary." -ForegroundColor Yellow
}
}
} else {
Write-Host "Qryon not on PATH and no working Podman image. Install: cargo install rma-cli (binary: qryon)" -ForegroundColor Yellow
$skipPath = Join-Path $reportsDir "qryon-skipped.txt"
Write-ReportTextLines -LiteralPath $skipPath -Lines @(
"Qryon did not run.",
"",
"Causes:",
" - Container images ghcr.io/bumahkib7/qryon:* fail in-container: binary requires GLIBC 2.38+ but the image base is older.",
" - Native CLI 'qryon' is not on PATH (install with: cargo install rma-cli).",
"",
"Options:",
" - cargo install rma-cli then ensure ~/.cargo/bin is on PATH; set qryon_use_podman: false.",
" - Or set qryon_enabled: false until upstream fixes the image.",
""
)
Write-Host "qryon: wrote skip summary -> $skipPath" -ForegroundColor DarkGray
Add-ScanNote "Qryon did not run (see qryon-skipped.txt)."
}
}
}
}
# --- DAST placeholder ---
if ($C["dast_enabled"]) {
Write-Host ""
Write-Host "=== DAST (manual) ===" -ForegroundColor Yellow
$base = $C["dast_base_url"]
$zapPort = $C["zap_port"]
Write-Host "dast_enabled is true. Run OWASP ZAP or Nuclei against: $base"
$zh = $C["zap_host"]
if (-not $zh) { $zh = "127.0.0.1" }
Write-Host "Example ZAP API/daemon port (config): ${zh}:$zapPort"
Write-Host "Podman (ZAP): podman run -u zap -p ${zapPort}:8080 docker.io/zaproxy/zap-stable zap.sh -daemon -host 0.0.0.0 -port 8080"
}
Write-Host ""
Write-ReportInventory
Write-Host ""
if ($failed) {
Write-Host "Security scan finished with failures." -ForegroundColor Red
Write-Host "Partial logs and reports may still be under: $reportsDir" -ForegroundColor DarkYellow
exit 1
}
if ($scanNotes.Count -gt 0) {
Write-Host "Security scan finished; all configured steps ran without hard failures." -ForegroundColor Green
Write-Host "Notes:" -ForegroundColor Yellow
foreach ($n in $scanNotes) { Write-Host " - $n" -ForegroundColor DarkYellow }
} else {
Write-Host "Security scan completed successfully." -ForegroundColor Green
}
Write-Host "Reports directory: $reportsDir" -ForegroundColor DarkGray
exit 0
+65
View File
@@ -0,0 +1,65 @@
# ByteDraft security scan settings (read by scripts/security-scan.ps1).
# Flat key: value lines only (no nested YAML). Lines starting with # are comments.
#
# --- Rust (native cargo tools) ---
cargo_deny_enabled: true
cargo_audit_enabled: true
cargo_clippy_enabled: true
cargo_clippy_deny_warnings: true
cargo_fmt_check_enabled: true
cargo_geiger_enabled: true
cargo_outdated_enabled: true
cargo_udeps_enabled: false
# --- Container-based scanners via Podman ---
# Run: podman machine start (Windows/Mac) before enabling these.
semgrep_enabled: true
semgrep_use_podman: true
semgrep_image: docker.io/semgrep/semgrep:latest
semgrep_config: p/rust
# Semgrep CE scans only git-tracked files; add/commit paths you want included.
trivy_enabled: true
trivy_use_podman: true
# Prefer Docker Hub if you use `podman login docker.io` (script pulls each candidate until one succeeds).
trivy_image: docker.io/aquasec/trivy:0.69.3
trivy_fallback_image: ghcr.io/aquasecurity/trivy:0.69.3
trivy_severity: HIGH,CRITICAL
# Comma-separated names under the scan root (e.g. target). Skipping target avoids walk errors on Windows+Podman bind mounts over Rust build dirs.
trivy_skip_dirs: target
# When false, Trivy uses --exit-code 0 so findings still print but the script step does not fail.
trivy_fail_on_findings: false
gitleaks_enabled: true
gitleaks_use_podman: true
# Official image (zricethezav/* on Docker Hub is legacy; avoids old entrypoints / git quirks).
gitleaks_image: ghcr.io/gitleaks/gitleaks:v8.30.1
gitleaks_no_git: false
# Podman runs always use --no-git (Windows bind-mount + git often breaks). Native gitleaks: --no-git if no HEAD or if true here.
osv_scanner_enabled: true
osv_scanner_use_podman: true
# Official image is on GHCR only (docker.io/google/osv-scanner is not published).
osv_scanner_image: ghcr.io/google/osv-scanner:stable
# Exit 1 means vulnerabilities/licenses were reported, not a crash. When false, the script continues (see output).
osv_scanner_fail_on_findings: false
# Qryon - SAST (https://github.com/bumahkib7/qryon). CLI from crates.io: `cargo install rma-cli` (binary: qryon).
# Script runs: qryon scan <repo> --no-color -p <profile> -f <format> -o <reports_dir>/qryon-report.<ext>
qryon_enabled: true
qryon_use_podman: false
qryon_profile: balanced
qryon_format: json
# When qryon_use_podman is true: same flags inside the container; output to /out/qryon-report.<ext>
qryon_image: ghcr.io/bumahkib7/qryon:0.20.1
qryon_fail_on_findings: false
# --- DAST (OWASP ZAP, Nuclei): only when you run a reachable HTTP service ---
dast_enabled: false
dast_base_url: https://localhost:8443
zap_host: 127.0.0.1
zap_port: 8090
# --- Output ---
# Podman mounts this directory at /out (rw). Expect semgrep-report.json, trivy-fs.txt, gitleaks-report.json (+ gitleaks-console.log), osv-scanner-report.json, qryon-report.<ext per qryon_format> or qryon-skipped.txt.
reports_dir: security/reports