Review: Allow Edit Ranges with Excel-compatible password hashes (#307)
- Model: Sheet.edit_ranges (title, ranges, optional password hash,
security descriptor) and gridcraft_model::password: the ECMA-376 legacy
16-bit hash and the salted, iterated SHA-2 hash (SHA-512/384/256; other
algorithms, damaged hashes and spin counts over 10,000,000 are refused).
New passwords use SHA-512, spin count 100000. Adds the sha2 crate.
- XLSX: <protectedRanges> read and written; an unreadable password keeps
the range locked.
- review.allowEditRanges {action: list|add|modify|delete|unlock, ...}: empty
params list; ranges can't change while the sheet is protected; unlock
verifies the password for the session. Not journaled (passwords).
- Locked-cell checks (commit guard, cell.set, picture in cell) let edits
through in open or unlocked ranges; ranges move with structural edits.
- Ribbon button and a form dialog (menu invocation only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f896c1249
commit
3b918ada2c
|
|
@ -447,6 +447,15 @@ version = "2.13.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
|
|
@ -697,6 +706,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
|
@ -746,6 +764,15 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
|
|
@ -776,6 +803,16 @@ dependencies = [
|
|||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1529,6 +1566,7 @@ dependencies = [
|
|||
"gridcraft-formula",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
|
@ -1663,6 +1701,15 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
|
|
@ -2480,7 +2527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3071,6 +3118,17 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
|
|
@ -3452,6 +3510,12 @@ dependencies = [
|
|||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ rayon = "1"
|
|||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.38"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# ui
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Allow Edit Ranges, catalog 259 / 290, #307) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
|
|
@ -21,7 +21,7 @@ This page is the summary. The detail is in:
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 259 / 290 (89.3%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
|
||||
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
|
||||
|
|
@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Review › Allow Edit Ranges with Excel-compatible password hashes, kept in XLSX (#307) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
|
|
@ -142,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Catalog 258 → 259 / 290: Allow Edit Ranges (#307) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
||||
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
|
||||
|
|
|
|||
|
|
@ -199,10 +199,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let input =
|
||||
str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default();
|
||||
let array = bool_param(p, "array").unwrap_or(false);
|
||||
let protected =
|
||||
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked));
|
||||
if protected {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet)
|
||||
&& sh.is_protected()
|
||||
&& d.wb.styles.get(sh.style_id(at)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
if array {
|
||||
|
|
|
|||
|
|
@ -782,7 +782,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
};
|
||||
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
|
||||
let style = sh.style_id(at);
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
|
||||
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
|||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb)?;
|
||||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
|
|
@ -292,7 +292,7 @@ pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is
|
|||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
continue;
|
||||
|
|
@ -302,14 +302,33 @@ fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
|
|||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
if !same_content(sh.cell(c), after.cell(c))
|
||||
&& old.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, unlocked)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
|
||||
/// Range without a password or one unlocked in this session, else the message to show.
|
||||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||||
let mut needs_password = None;
|
||||
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
|
||||
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||||
return None;
|
||||
}
|
||||
needs_password.get_or_insert(r.title.as_str());
|
||||
}
|
||||
Some(match needs_password {
|
||||
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
|
||||
None => PROTECTED.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Review tab: protection, spelling (basic), workbook statistics.
|
||||
|
||||
use gridcraft_model::{SheetProtection, password_hash};
|
||||
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, password_hash};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
|
|
@ -19,6 +19,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
|
||||
cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook),
|
||||
cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
|
||||
// Not journaled: the params can hold a password.
|
||||
CommandSpec {
|
||||
id: "review.allowEditRanges",
|
||||
label: "Allow Edit Ranges…",
|
||||
menu: &["Review", "Protect"],
|
||||
shortcut: None,
|
||||
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
|
||||
enabled: has_doc,
|
||||
run: allow_edit_ranges,
|
||||
journal: false,
|
||||
undoable: true,
|
||||
},
|
||||
cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
|
||||
]
|
||||
}
|
||||
|
|
@ -60,6 +72,117 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
fn range_text(r: &EditRange) -> String {
|
||||
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
|
||||
/// protected sheet, or unlock a password range for this session.
|
||||
fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let action = str_param(p, "action").unwrap_or("list");
|
||||
let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let sheet_name = sh.name.clone();
|
||||
let find = |t: Option<&str>| -> Result<usize> {
|
||||
let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?;
|
||||
sh.edit_ranges
|
||||
.iter()
|
||||
.position(|r| r.title.eq_ignore_ascii_case(t))
|
||||
.ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
|
||||
};
|
||||
let hash = |pw: &str, title: &str| {
|
||||
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
|
||||
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
|
||||
};
|
||||
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
|
||||
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
|
||||
}
|
||||
match action {
|
||||
"list" => {}
|
||||
"unlock" => {
|
||||
let i = find(title)?;
|
||||
let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) };
|
||||
if let Some(h) = &r.password {
|
||||
let pw = str_param(p, "password").unwrap_or("");
|
||||
if !h.verify(pw).map_err(EngineError::Other)? {
|
||||
return Err(EngineError::Other("The password you supplied is not correct.".into()));
|
||||
}
|
||||
let key = (sheet_name.clone(), r.title.clone());
|
||||
let d = s.doc_mut()?;
|
||||
if !d.unlocked_ranges.contains(&key) {
|
||||
d.unlocked_ranges.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
"add" => {
|
||||
let t = match title {
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
(1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) {
|
||||
return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists")));
|
||||
}
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t));
|
||||
let r = EditRange { title: t, ranges, password, security_descriptor: None };
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.edit_ranges.push(r.clone());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"modify" => {
|
||||
let i = find(title)?;
|
||||
let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?;
|
||||
if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists")));
|
||||
}
|
||||
r.title = n.to_string();
|
||||
}
|
||||
if let Some(pw) = str_param(p, "password") {
|
||||
r.password = (!pw.is_empty()).then(|| hash(pw, &r.title));
|
||||
}
|
||||
if p.get("range").is_some() {
|
||||
r.ranges = target_ranges(s, p)?;
|
||||
}
|
||||
edit(s, |cx| {
|
||||
if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) {
|
||||
*x = r.clone();
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"delete" => {
|
||||
let i = find(title)?;
|
||||
edit(s, |cx| {
|
||||
let list = &mut cx.sheet_mut(sheet)?.edit_ranges;
|
||||
if i < list.len() {
|
||||
list.remove(i);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))),
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
Ok(Json::Array(
|
||||
sh.edit_ranges
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title);
|
||||
json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let cur = s.doc()?.wb.protected_structure;
|
||||
let on = bool_param(p, "on").unwrap_or(!cur);
|
||||
|
|
@ -97,3 +220,61 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
|
|||
}
|
||||
Ok(Json::Array(issues))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn protected_book() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_ranges_open_locked_cells_and_passwords_really_verify() {
|
||||
let mut s = protected_book();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
|
||||
assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err());
|
||||
let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Budget"), "{err}");
|
||||
// A paste or fill is checked the same way.
|
||||
assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err());
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err());
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err());
|
||||
let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap();
|
||||
assert_eq!(list[1]["unlocked"], true);
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap();
|
||||
// Ranges can't change while the sheet is protected.
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_ranges_add_modify_delete_list_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
// Empty params list; nothing to list yet.
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
|
||||
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
|
||||
let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap();
|
||||
assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}]));
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err());
|
||||
let list = s
|
||||
.execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"}))
|
||||
.unwrap();
|
||||
assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true)));
|
||||
// Inserting a row above moves the range with its cells.
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6");
|
||||
s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap();
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine");
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err());
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
|||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect();
|
||||
}
|
||||
for er in sh.edit_ranges.iter_mut() {
|
||||
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect();
|
||||
}
|
||||
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
|
||||
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect();
|
||||
|
|
@ -693,6 +697,10 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
|||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
for er in sh.edit_ranges.iter_mut() {
|
||||
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
|
||||
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ pub struct DocState {
|
|||
pub title: String,
|
||||
/// Per-sheet selection memory.
|
||||
pub sheet_selections: Vec<Selection>,
|
||||
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
|
||||
pub unlocked_ranges: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
|
@ -119,6 +121,7 @@ impl DocState {
|
|||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
sheet_selections: vec![],
|
||||
unlocked_ranges: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
gridcraft-core = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -549,6 +549,24 @@ pub struct SheetProtection {
|
|||
pub autofilter: bool,
|
||||
}
|
||||
|
||||
/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a
|
||||
/// password (XLSX `<protectedRange>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EditRange {
|
||||
pub title: String,
|
||||
pub ranges: Vec<RangeRef>,
|
||||
pub password: Option<crate::PasswordHash>,
|
||||
/// Windows permissions (`securityDescriptor`), kept as read from the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub security_descriptor: Option<String>,
|
||||
}
|
||||
|
||||
impl EditRange {
|
||||
pub fn contains(&self, c: CellRef) -> bool {
|
||||
self.ranges.iter().any(|r| r.contains(c))
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a
|
||||
/// convenience, not security).
|
||||
pub fn password_hash(pw: &str) -> u64 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ mod b64;
|
|||
pub mod cell;
|
||||
mod cellmap;
|
||||
pub mod features;
|
||||
pub mod password;
|
||||
pub mod sheet;
|
||||
pub mod store;
|
||||
pub mod style;
|
||||
|
|
@ -15,6 +16,7 @@ pub mod workbook;
|
|||
|
||||
pub use cell::{Cell, CellPicture, Formula};
|
||||
pub use features::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! Excel-compatible password hashes for protection (ECMA-376 Part 1 §18.2.29 and §18.3.1.66
|
||||
//! attribute sets): the legacy 16-bit hash (`password="CC1A"`) and the iterated, salted hash
|
||||
//! (`algorithmName`, `hashValue`, `saltValue`, `spinCount`). Protection is a convenience, not
|
||||
//! security, but verification really checks the password: an unsupported variant is refused,
|
||||
//! never accepted.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
|
||||
/// Excel's default spin count for new passwords.
|
||||
pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
|
||||
/// Largest spin count accepted when verifying (the documented maximum); a file asking for more
|
||||
/// would only make verification hang.
|
||||
pub const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PasswordHash {
|
||||
/// The legacy 16-bit hash.
|
||||
Legacy(u16),
|
||||
/// An iterated, salted hash as stored in the file: algorithm name, base64 hash and salt.
|
||||
Iterated { algorithm: String, hash: String, salt: String, spin_count: u32 },
|
||||
}
|
||||
|
||||
impl PasswordHash {
|
||||
/// A SHA-512 hash with Excel's default spin count, for a new password.
|
||||
pub fn new_sha512(password: &str, salt: &[u8]) -> PasswordHash {
|
||||
let hash = iterated::<sha2::Sha512>(password, salt, DEFAULT_SPIN_COUNT);
|
||||
PasswordHash::Iterated {
|
||||
algorithm: "SHA-512".into(),
|
||||
hash: crate::b64::encode(&hash),
|
||||
salt: crate::b64::encode(salt),
|
||||
spin_count: DEFAULT_SPIN_COUNT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `password` matches. `Err` when the hash can't be checked (unsupported algorithm,
|
||||
/// broken base64, spin count over [`MAX_SPIN_COUNT`]).
|
||||
pub fn verify(&self, password: &str) -> Result<bool, String> {
|
||||
match self {
|
||||
PasswordHash::Legacy(h) => Ok(legacy_hash(password) == *h),
|
||||
PasswordHash::Iterated { algorithm, hash, salt, spin_count } => {
|
||||
if algorithm.is_empty() {
|
||||
return Err("the stored password hash is damaged".into());
|
||||
}
|
||||
if *spin_count > MAX_SPIN_COUNT {
|
||||
return Err(format!("the password uses a spin count of {spin_count}, more than the {MAX_SPIN_COUNT} allowed"));
|
||||
}
|
||||
let (Some(want), Some(salt)) = (crate::b64::decode(hash), crate::b64::decode(salt)) else {
|
||||
return Err("the stored password hash is damaged".into());
|
||||
};
|
||||
let got = match algorithm.to_ascii_uppercase().replace('_', "-").as_str() {
|
||||
"SHA-512" => iterated::<sha2::Sha512>(password, &salt, *spin_count),
|
||||
"SHA-384" => iterated::<sha2::Sha384>(password, &salt, *spin_count),
|
||||
"SHA-256" => iterated::<sha2::Sha256>(password, &salt, *spin_count),
|
||||
_ => return Err(format!("passwords hashed with {algorithm} aren't supported")),
|
||||
};
|
||||
Ok(got == want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16-byte salt derived from `seed` (callers mix in what varies: names, a counter, the clock).
|
||||
pub fn salt_from(seed: &[u8]) -> [u8; 16] {
|
||||
let h = sha2::Sha512::digest(seed);
|
||||
let mut out = [0u8; 16];
|
||||
for (o, b) in out.iter_mut().zip(h.iter()) {
|
||||
*o = *b;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The legacy 16-bit password hash: each UTF-16 unit contributes its low byte (its high byte
|
||||
/// when the low one is zero), folded in from the last character with a 15-bit rotation, then
|
||||
/// the length and the constant `0xCE4B`.
|
||||
pub fn legacy_hash(password: &str) -> u16 {
|
||||
let bytes: Vec<u16> = password.encode_utf16().map(|u| if u & 0xFF != 0 { u & 0xFF } else { u >> 8 }).collect();
|
||||
let rotate = |v: u16| ((v >> 14) & 1) | ((v << 1) & 0x7FFF);
|
||||
let mut h: u16 = 0;
|
||||
for b in bytes.iter().rev() {
|
||||
h = rotate(h) ^ b;
|
||||
}
|
||||
h = rotate(h) ^ (bytes.len() as u16);
|
||||
h ^ 0xCE4B
|
||||
}
|
||||
|
||||
/// H0 = H(salt + UTF-16LE password); Hn = H(Hn-1 + n as 32-bit little-endian), `spin` times.
|
||||
fn iterated<D: Digest>(password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
d.update(salt);
|
||||
for u in password.encode_utf16() {
|
||||
d.update(u.to_le_bytes());
|
||||
}
|
||||
let mut h = d.finalize().to_vec();
|
||||
for i in 0..spin {
|
||||
let mut d = D::new();
|
||||
d.update(&h);
|
||||
d.update(i.to_le_bytes());
|
||||
h = d.finalize().to_vec();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_hash_matches_the_published_example() {
|
||||
// The hash Excel writes for the password "password" (`password="83AF"`).
|
||||
assert_eq!(legacy_hash("password"), 0x83AF);
|
||||
assert!(PasswordHash::Legacy(0x83AF).verify("password").unwrap());
|
||||
assert!(!PasswordHash::Legacy(0x83AF).verify("Password").unwrap());
|
||||
assert_eq!(legacy_hash(""), 0xCE4B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterated_hash_verifies_and_refuses_what_it_cannot_check() {
|
||||
let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef");
|
||||
assert!(h.verify("s3cret").unwrap());
|
||||
assert!(!h.verify("s3cret ").unwrap());
|
||||
assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512"));
|
||||
let PasswordHash::Iterated { hash, salt, .. } = h else { return };
|
||||
let other =
|
||||
|algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count };
|
||||
assert!(other("SHA-1", 10).verify("s3cret").is_err());
|
||||
assert!(other("MD5", 10).verify("s3cret").is_err());
|
||||
assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err());
|
||||
assert!(!other("SHA-256", 2).verify("s3cret").unwrap());
|
||||
let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 };
|
||||
assert!(broken.verify("s3cret").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,9 @@ pub struct Sheet {
|
|||
#[serde(default)]
|
||||
pub pivots: Vec<PivotTable>,
|
||||
pub protection: Option<SheetProtection>,
|
||||
/// Allow Edit Ranges (meaningful while the sheet is protected).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edit_ranges: Vec<crate::EditRange>,
|
||||
pub print: PrintSettings,
|
||||
/// Last selection and scroll position, saved with the file.
|
||||
pub view_active: CellRef,
|
||||
|
|
@ -108,6 +111,7 @@ impl Sheet {
|
|||
sparklines: vec![],
|
||||
pivots: vec![],
|
||||
protection: None,
|
||||
edit_ranges: vec![],
|
||||
print: PrintSettings::default(),
|
||||
view_active: CellRef::default(),
|
||||
view_top_left: CellRef::default(),
|
||||
|
|
|
|||
|
|
@ -163,6 +163,44 @@ impl Dialog {
|
|||
],
|
||||
json!({}),
|
||||
),
|
||||
"allowEditRanges" => {
|
||||
let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null);
|
||||
let rows: Vec<String> = list
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|r| {
|
||||
let lock = match (r["password"].as_bool(), r["unlocked"].as_bool()) {
|
||||
(Some(true), Some(true)) => " (password, unlocked)",
|
||||
(Some(true), _) => " (password)",
|
||||
_ => "",
|
||||
};
|
||||
format!("{}: {}{lock}", r["title"].as_str().unwrap_or(""), r["range"].as_str().unwrap_or(""))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let summary = if rows.is_empty() { "No ranges yet.".to_string() } else { rows.join("\n") };
|
||||
Dialog::form(
|
||||
"allowEditRanges",
|
||||
"Allow Edit Ranges",
|
||||
"review.allowEditRanges",
|
||||
vec![
|
||||
Note(format!("Ranges that stay editable when the sheet is protected:\n{summary}")),
|
||||
Choice {
|
||||
key: "action",
|
||||
label: "Action",
|
||||
options: vec![("add", "New"), ("modify", "Modify"), ("delete", "Delete"), ("unlock", "Unlock with password")],
|
||||
},
|
||||
Text { key: "title", label: "Title:" },
|
||||
Text { key: "range", label: "Refers to cells:" },
|
||||
Text { key: "password", label: "Range password:" },
|
||||
Text { key: "newTitle", label: "New title (Modify):" },
|
||||
Check { key: "removePassword", label: "Remove the password (Modify)" },
|
||||
],
|
||||
json!({"action": "add", "range": sel}),
|
||||
)
|
||||
}
|
||||
"unprotectSheet" => Dialog::form(
|
||||
"unprotectSheet",
|
||||
"Unprotect Sheet",
|
||||
|
|
@ -614,6 +652,15 @@ fn build_params(d: &Dialog) -> Json {
|
|||
v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect());
|
||||
}
|
||||
}
|
||||
"allowEditRanges" => {
|
||||
if let Some(m) = v.as_object_mut() {
|
||||
// Blank fields mean "unchanged"; the check box clears a password.
|
||||
m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty()));
|
||||
if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) {
|
||||
m.insert("password".into(), json!(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
if let Some(p) = d.values.get("preset").and_then(Json::as_str)
|
||||
&& d.values.get("percent").is_none()
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Threaded Comments", "スレッド化されたコメント"),
|
||||
("Show\nComments", "コメントの表示"),
|
||||
("Protect\nWorkbook", "ブックの保護"),
|
||||
("Allow Edit\nRanges", "範囲の編集を許可"),
|
||||
("Allow Edit Ranges", "範囲の編集を許可"),
|
||||
("Unprotect\nSheet", "シート保護の解除"),
|
||||
("Protect Sheet…", "シートの保護…"),
|
||||
("Comments", "コメント"),
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "스레드 댓글",
|
||||
"Show\nComments" => "댓글\n표시",
|
||||
"Protect\nWorkbook" => "통합 문서\n보호",
|
||||
"Allow Edit\nRanges" => "범위 편집\n허용",
|
||||
"Allow Edit Ranges" => "범위 편집 허용",
|
||||
"Unprotect\nSheet" => "시트 보호\n해제",
|
||||
"Protect Sheet…" => "시트 보호…",
|
||||
"Comments" => "댓글",
|
||||
|
|
|
|||
|
|
@ -174,6 +174,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "Comentários Encadeados",
|
||||
"Show\nComments" => "Mostrar\nComentários",
|
||||
"Protect\nWorkbook" => "Proteger\nPasta de Trabalho",
|
||||
"Allow Edit\nRanges" => "Permitir Edição\nde Intervalos",
|
||||
"Allow Edit Ranges" => "Permitir Edição de Intervalos",
|
||||
"Unprotect\nSheet" => "Desproteger\nPlanilha",
|
||||
"Protect Sheet…" => "Proteger Planilha…",
|
||||
"Comments" => "Comentários",
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "Комментарии с ветками",
|
||||
"Show\nComments" => "Показать\nкомментарии",
|
||||
"Protect\nWorkbook" => "Защитить\nкнигу",
|
||||
"Allow Edit\nRanges" => "Разрешить\nизменение диапазонов",
|
||||
"Allow Edit Ranges" => "Разрешить изменение диапазонов",
|
||||
"Unprotect\nSheet" => "Снять защиту\nлиста",
|
||||
"Protect Sheet…" => "Защитить лист…",
|
||||
"Comments" => "Комментарии",
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "线程式批注",
|
||||
"Show\nComments" => "显示\n批注",
|
||||
"Protect\nWorkbook" => "保护\n工作簿",
|
||||
"Allow Edit\nRanges" => "允许编辑\n区域",
|
||||
"Allow Edit Ranges" => "允许编辑区域",
|
||||
"Unprotect\nSheet" => "撤销\n工作表保护",
|
||||
"Protect Sheet…" => "保护工作表…",
|
||||
"Comments" => "批注",
|
||||
|
|
|
|||
|
|
@ -1618,6 +1618,9 @@ fn review(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() {
|
||||
act(app, "review.protectWorkbook", json!({}));
|
||||
}
|
||||
if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() {
|
||||
app.open_dialog("allowEditRanges", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn view(app: &mut SheetApp, ui: &mut Ui) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use gridcraft_core::date::{serial_from_ymd, time_fraction};
|
|||
use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{
|
||||
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Orientation,
|
||||
Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind,
|
||||
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo,
|
||||
Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind,
|
||||
};
|
||||
|
||||
use crate::IoError;
|
||||
|
|
@ -183,6 +183,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
|||
}
|
||||
}
|
||||
"extLst" => read_ext(cx, e, &mut sheet),
|
||||
"protectedRanges" => read_protected_ranges(e, &mut sheet),
|
||||
"oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")),
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -201,6 +202,33 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
|||
Ok(sheet)
|
||||
}
|
||||
|
||||
/// Allow Edit Ranges. A range whose password can't be read keeps a hash that never matches
|
||||
/// (or an unsupported algorithm), so it stays locked rather than open.
|
||||
fn read_protected_ranges(e: &El, sheet: &mut Sheet) {
|
||||
for p in e.kids("protectedRange") {
|
||||
let ranges: Vec<RangeRef> = p.attr("sqref").unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect();
|
||||
if ranges.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let password = if p.attr("algorithmName").is_some() || p.attr("hashValue").is_some() {
|
||||
Some(PasswordHash::Iterated {
|
||||
algorithm: p.attr("algorithmName").unwrap_or("").to_string(),
|
||||
hash: p.attr("hashValue").unwrap_or("").to_string(),
|
||||
salt: p.attr("saltValue").unwrap_or("").to_string(),
|
||||
spin_count: p.attr_u32("spinCount").unwrap_or(0),
|
||||
})
|
||||
} else {
|
||||
p.attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) {
|
||||
Ok(h) => PasswordHash::Legacy(h),
|
||||
// Unreadable: keep it locked.
|
||||
Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 },
|
||||
})
|
||||
};
|
||||
let title = p.attr("name").unwrap_or("").to_string();
|
||||
sheet.edit_ranges.push(EditRange { title, ranges, password, security_descriptor: p.attr("securityDescriptor").map(str::to_string) });
|
||||
}
|
||||
}
|
||||
|
||||
fn read_views(e: &El, sheet: &mut Sheet) {
|
||||
let Some(v) = e.child("sheetView") else { return };
|
||||
sheet.show_gridlines = v.flag("showGridLines", true);
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
|||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
s.push_str(&protected_ranges_xml(sheet));
|
||||
// autoFilter (a sheet filter overlapping a table would be invalid).
|
||||
if let Some(af) = &sheet.autofilter
|
||||
&& !sheet.tables.iter().any(|t| t.range.intersects(&af.range))
|
||||
|
|
@ -996,6 +997,35 @@ fn comments_xml(sheet: &Sheet) -> String {
|
|||
s
|
||||
}
|
||||
|
||||
/// Allow Edit Ranges: `<protectedRanges>` (after `sheetProtection`).
|
||||
fn protected_ranges_xml(sheet: &Sheet) -> String {
|
||||
let mut s = String::new();
|
||||
for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) {
|
||||
let sqref: Vec<String> = r.ranges.iter().map(|x| x.a1()).collect();
|
||||
let _ = write!(s, "<protectedRange name=\"{}\" sqref=\"{}\"", esc_attr(&r.title), sqref.join(" "));
|
||||
match &r.password {
|
||||
Some(gridcraft_model::PasswordHash::Legacy(h)) => {
|
||||
let _ = write!(s, " password=\"{h:04X}\"");
|
||||
}
|
||||
Some(gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count }) => {
|
||||
let _ = write!(
|
||||
s,
|
||||
" algorithmName=\"{}\" hashValue=\"{}\" saltValue=\"{}\" spinCount=\"{spin_count}\"",
|
||||
esc_attr(algorithm),
|
||||
esc_attr(hash),
|
||||
esc_attr(salt)
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(sd) = &r.security_descriptor {
|
||||
let _ = write!(s, " securityDescriptor=\"{}\"", esc_attr(sd));
|
||||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
if s.is_empty() { s } else { format!("<protectedRanges>{s}</protectedRanges>") }
|
||||
}
|
||||
|
||||
/// Legacy VML shapes Excel uses to display notes.
|
||||
fn vml_xml(sheet: &Sheet, n: u32) -> String {
|
||||
let mut s = format!(
|
||||
|
|
|
|||
|
|
@ -121,3 +121,18 @@ fn zip_bomb_is_rejected() {
|
|||
assert!(bytes.len() < 10 << 20);
|
||||
assert!(matches!(read_xlsx(&bytes), Err(IoError::TooLarge(_))));
|
||||
}
|
||||
|
||||
/// Allow Edit Ranges from a hostile file: bad references are dropped and a password that can't be
|
||||
/// read keeps the range locked instead of opening it.
|
||||
#[test]
|
||||
fn hostile_protected_ranges_stay_locked() {
|
||||
let body = r#"<sheetData/><protectedRanges><protectedRange name="bad ref" sqref="ZZZZZ1 A0"/><protectedRange name="hex" sqref="A1" password="XYZ"/><protectedRange name="spin" sqref="B1" algorithmName="SHA-512" hashValue="AAAA" saltValue="AAAA" spinCount="4294967295"/><protectedRange name="half" sqref="C1" hashValue="AAAA"/><protectedRange name="md5" sqref="D1" algorithmName="MD5" hashValue="AAAA" saltValue="AAAA" spinCount="1"/></protectedRanges>"#;
|
||||
let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap();
|
||||
let ranges = &wb.sheets[0].edit_ranges;
|
||||
assert_eq!(ranges.iter().map(|r| r.title.as_str()).collect::<Vec<_>>(), ["hex", "spin", "half", "md5"]);
|
||||
for r in ranges {
|
||||
let h = r.password.as_ref().unwrap();
|
||||
assert!(!matches!(h.verify(""), Ok(true)), "{}", r.title);
|
||||
assert!(!matches!(h.verify("password"), Ok(true)), "{}", r.title);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,21 @@ fn sample() -> Workbook {
|
|||
allow_blank: false,
|
||||
..Default::default()
|
||||
});
|
||||
s.edit_ranges = vec![
|
||||
EditRange { title: "Open & <free>".into(), ranges: vec![rr("A1:B2"), rr("D4")], password: None, security_descriptor: None },
|
||||
EditRange {
|
||||
title: "Old".into(),
|
||||
ranges: vec![rr("C1:C9")],
|
||||
password: Some(PasswordHash::Legacy(0x83AF)),
|
||||
security_descriptor: Some("O:WDG:WDD:(A;;CC;;;WD)".into()),
|
||||
},
|
||||
EditRange {
|
||||
title: "New".into(),
|
||||
ranges: vec![rr("E1")],
|
||||
password: Some(PasswordHash::new_sha512("pw", b"0123456789abcdef")),
|
||||
security_descriptor: None,
|
||||
},
|
||||
];
|
||||
s.hyperlinks.insert(at("A2"), Hyperlink { target: "https://example.com/?a=1&b=2".into(), tooltip: Some("go".into()) });
|
||||
s.hyperlinks.insert(at("A3"), Hyperlink { target: "'Data & Stuff'!B2".into(), tooltip: None });
|
||||
s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None });
|
||||
|
|
@ -404,6 +419,7 @@ fn full_roundtrip() {
|
|||
assert_eq!(b.comments, a.comments);
|
||||
assert_eq!(b.autofilter, a.autofilter);
|
||||
assert_eq!(b.protection, a.protection);
|
||||
assert_eq!(b.edit_ranges, a.edit_ranges);
|
||||
assert_eq!(b.print, a.print);
|
||||
let mut sp = a.sparklines.clone();
|
||||
sp[0].source = "'Data & Stuff'!B2:B3".into();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX `protectedRanges`, #307) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
|
|
@ -15,7 +15,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
|
|||
|
||||
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|
||||
|---|---|---|---|---|---|
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
|
||||
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — |
|
||||
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — |
|
||||
|
|
@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | XLSX: Allow Edit Ranges (`protectedRanges`) read and written with their password hashes (#307) |
|
||||
| 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) |
|
||||
| 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Allow Edit Ranges landed, #307) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
|
|
@ -110,7 +110,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
[function-parity.md](function-parity.md)
|
||||
|
||||
### 16. Collaboration and review (F)
|
||||
- No co-authoring, Show Changes, Allow Edit Ranges, comment navigation, notes ↔ comments
|
||||
- No co-authoring, Show Changes, comment navigation, notes ↔ comments
|
||||
conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner).
|
||||
|
||||
### 17. Insert objects (F)
|
||||
|
|
@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | §16: Allow Edit Ranges landed (#307) |
|
||||
| 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks the alpha gate |
|
||||
| 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**Overall: 258 / 290 commands implemented (89.0%).**
|
||||
**Overall: 259 / 290 commands implemented (89.3%).**
|
||||
|
||||
| Tab | Implemented | Total | Parity |
|
||||
|---|---|---|---|
|
||||
|
|
@ -15,7 +15,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
|
|||
| Page Layout | 23 | 23 | 100% |
|
||||
| Formulas | 23 | 23 | 100% |
|
||||
| Data | 20 | 28 | 71% |
|
||||
| Review | 9 | 18 | 50% |
|
||||
| Review | 10 | 18 | 56% |
|
||||
| View | 18 | 20 | 90% |
|
||||
| Automate | 3 | 3 | 100% |
|
||||
| Table Design | 12 | 13 | 92% |
|
||||
|
|
@ -276,7 +276,7 @@ Missing:
|
|||
- `data.solver` Solver (Analysis)
|
||||
- `data.analysisToolPak` Data Analysis (Analysis)
|
||||
|
||||
## Review (9 / 18, 50%)
|
||||
## Review (10 / 18, 56%)
|
||||
|
||||
Implemented:
|
||||
|
||||
|
|
@ -289,6 +289,7 @@ Implemented:
|
|||
- `review.showNote` Show/Hide Note (Notes)
|
||||
- `review.unprotectSheet` Unprotect Sheet (Protect)
|
||||
- `review.protectWorkbook` Protect Workbook (Protect)
|
||||
- `review.allowEditRanges` Allow Edit Ranges (Protect)
|
||||
|
||||
Missing:
|
||||
|
||||
|
|
@ -299,7 +300,6 @@ Missing:
|
|||
- `review.showComments` Show Comments (Comments)
|
||||
- `review.showAllNotes` Show All Notes (Notes)
|
||||
- `review.convertToComments` Convert to Comments (Notes)
|
||||
- `review.allowEditRanges` Allow Edit Ranges (Protect)
|
||||
- `review.showChanges` Show Changes (Changes)
|
||||
|
||||
## View (18 / 20, 90%)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Target-app parity: GridCraft vs Microsoft Excel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Allow Edit Ranges, catalog 259 / 290, #307) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||
|
|
@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|
|||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements |
|
||||
| Ribbon and menu catalog | **258 / 290 (89.0%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
|
||||
| Ribbon and menu catalog | **259 / 290 (89.3%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
|
||||
| Worksheet functions, by name | **501 / 523 (95.8%)** | measured: registered functions vs Microsoft's published function list (method below) |
|
||||
| Worksheet functions that do real work | **492 / 523 (94.1%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) |
|
||||
| **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) |
|
||||
|
|
@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|
|||
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data |
|
||||
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
|
||||
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
|
||||
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
|
||||
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
|
||||
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility |
|
||||
| **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |
|
||||
|
||||
|
|
@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Review › Allow Edit Ranges; catalog 258 → 259 / 290 (#307) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
|
||||
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |
|
||||
|
|
|
|||
Loading…
Reference in New Issue