Merge pull request #387 from vangel76/fix/sheet-protection-password

Review: Protect Sheet passwords use Excel-compatible hashes, kept in XLSX (#346)

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas 2026-10-11 15:43:46 -04:00
commit f173d6733d
16 changed files with 238 additions and 61 deletions

View File

@ -137,6 +137,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-11 | Shrink to fit draws too-wide text and numbers in a smaller font inside the cell or merged area (#375) |
| 2026-10-11 | Shift+Space and Ctrl+Space select the row / column without starting an edit; Control+Space on Mac (#338) |
| 2026-10-11 | Workbooks reopen at their saved scroll position (`topLeftCell`, also with frozen panes) and save the current one back (#378) |
| 2026-10-11 | Protect Sheet passwords use Excel-compatible hashes and round-trip in XLSX `sheetProtection` (imported passwords verify instead of being dropped) |
| 2026-10-11 | Review › Protect Workbook opens a dialog with an optional password (Excel-compatible hash, kept in XLSX `workbookProtection`); unprotecting verifies it (#345) |
| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) |
| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) |
| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) |

View File

@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
protect_sheet
),
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!(
"review.protectWorkbook",
"Protect Workbook",
["Review", "Protect"],
None,
"{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)",
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 {
@ -124,9 +132,16 @@ fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
// Re-protecting a password-protected sheet would replace the password without knowing it:
// unprotect it first, as in Excel. One without a password can take new options directly.
let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some();
if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) {
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
}
let b = |k: &str| bool_param(p, k).unwrap_or(false);
let prot = SheetProtection {
password_hash: str_param(p, "password").filter(|x| !x.is_empty()).map(password_hash),
password: None,
legacy_password_hash: None,
select_locked: true,
select_unlocked: true,
format_cells: b("formatCells"),
@ -139,6 +154,9 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sort: b("sort"),
autofilter: b("autofilter"),
};
let d = s.doc()?;
let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot };
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = Some(prot.clone());
Ok(Json::Null)
@ -147,11 +165,16 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ph = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).and_then(|pr| pr.password_hash);
if let Some(h) = ph
&& str_param(p, "password").map(password_hash) != Some(h)
{
return Err(EngineError::Other("The password you supplied is not correct.".into()));
if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) {
let pw = str_param(p, "password").unwrap_or("");
let ok = match (&pr.password, pr.legacy_password_hash) {
(Some(h), _) => h.verify(pw).map_err(EngineError::Other)?,
(None, Some(h)) => password_hash(pw) == h,
(None, None) => true,
};
if !ok {
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = None;
@ -159,6 +182,15 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected
/// with the document, a counter and the clock, so equal passwords get different hashes.
fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{what}\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()))
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
}
@ -166,7 +198,6 @@ fn range_text(r: &EditRange) -> String {
/// 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());
@ -180,11 +211,7 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
.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()))
};
let hash = |pw: &str, title: &str| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw);
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
}
@ -270,11 +297,28 @@ fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
))
}
/// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved,
/// renamed, hidden or deleted), optionally with a password that unprotecting must match.
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
let cur = s.doc()?.wb.protected_structure;
let d = s.doc()?;
let cur = d.wb.protected_structure;
let on = bool_param(p, "on").unwrap_or(!cur);
if on == cur {
return Ok(json!({"on": on}));
}
let password = if on {
str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw))
} else {
if let Some(h) = &d.wb.structure_password
&& !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)?
{
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
None
};
edit(s, |cx| {
cx.wb.protected_structure = on;
cx.wb.structure_password = password.clone();
Ok(json!({"on": on}))
})
}
@ -355,6 +399,53 @@ mod tests {
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
let mut s = book();
s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap();
let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap();
assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512"));
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err());
// Protecting again can't swap the password out from under it.
assert!(s.execute("review.protectSheet", json!({})).is_err());
assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot));
s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().is_protected());
// A GridCraft JSON workbook saved before carried the internal hash under `password_hash`.
let old = format!(
r#"{{"password_hash": {}, "select_locked": true, "select_unlocked": true, "format_cells": false, "format_columns": false, "format_rows": false, "insert_columns": false, "insert_rows": false, "delete_columns": false, "delete_rows": false, "sort": false, "autofilter": false}}"#,
password_hash("old")
);
let prot: SheetProtection = serde_json::from_str(&old).unwrap();
assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
let i = s.doc().unwrap().wb.active_sheet;
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
}
#[test]
fn protect_workbook_with_a_password_really_verifies() {
let mut s = book();
s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap();
assert!(s.doc().unwrap().wb.protected_structure);
assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked");
// A second Protect keeps it protected; unprotecting needs the right password.
assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true);
assert!(s.execute("review.protectWorkbook", json!({})).is_err());
assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err());
assert!(s.doc().unwrap().wb.protected_structure);
s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap();
let wb = &s.doc().unwrap().wb;
assert!(!wb.protected_structure && wb.structure_password.is_none());
// Without a password it toggles freely.
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(!s.doc().unwrap().wb.protected_structure);
}
#[test]
fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
let mut s = book();

View File

@ -816,7 +816,7 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
if id == "review.protectSheet" {
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password.is_none()), "{prot:?}");
}
}
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());

View File

@ -574,8 +574,14 @@ impl Default for PrintSettings {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SheetProtection {
/// Hash of the password (our own salted FNV; never the password itself).
pub password_hash: Option<u64>,
/// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the
/// password itself).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<crate::PasswordHash>,
/// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still
/// verified when unprotecting, never written for new passwords.
#[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")]
pub legacy_password_hash: Option<u64>,
pub select_locked: bool,
pub select_unlocked: bool,
pub format_cells: bool,
@ -607,8 +613,8 @@ impl EditRange {
}
}
/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a
/// convenience, not security).
/// The non-cryptographic password hash older GridCraft versions stored for sheet protection
/// (only verified now, see [`SheetProtection::legacy_password_hash`]).
pub fn password_hash(pw: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443;
for b in pw.bytes() {

View File

@ -67,6 +67,9 @@ pub struct Workbook {
pub props: DocProps,
/// Workbook structure protection.
pub protected_structure: bool,
/// Password of the structure protection (XLSX `workbookProtection`), never the password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structure_password: Option<crate::PasswordHash>,
/// Custom fill lists (Options › Custom Lists) beyond the built-in ones.
pub custom_lists: Vec<Vec<String>>,
/// Named cell styles (Cell Styles gallery): name → style.
@ -100,6 +103,7 @@ impl Workbook {
active_sheet: 0,
props: DocProps::default(),
protected_structure: false,
structure_password: None,
custom_lists: vec![],
cell_styles: vec![],
vba_project: None,

View File

@ -422,6 +422,20 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
recent_list(app, ui, out, usize::MAX);
}
/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an
/// optional password) when the structure is open; when it's locked, the password prompt if one
/// was set, else unprotect right away.
pub fn protect_workbook(app: &mut SheetApp) {
let (on, password) = app.session.active().map(|d| (d.wb.protected_structure, d.wb.structure_password.is_some())).unwrap_or((false, false));
if !on {
app.open_dialog("protectWorkbook", json!({}));
} else if password {
app.open_dialog("unprotectWorkbook", json!({}));
} else {
app.run_or_alert("review.protectWorkbook", json!({"on": false}));
}
}
fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
@ -451,7 +465,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
ui.add_space(18.0);
ui.horizontal(|ui| {
if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() {
app.run_or_alert("review.protectWorkbook", json!({"on": !protected}));
protect_workbook(app);
}
ui.add_space(12.0);
ui.vertical(|ui| {

View File

@ -219,6 +219,23 @@ impl Dialog {
json!({"action": "add", "range": sel}),
)
}
"protectWorkbook" => Dialog::form(
"protectWorkbook",
"Protect Structure and Windows",
"review.protectWorkbook",
vec![
Password { key: "password", label: "Password (optional):" },
Note("Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted).".into()),
],
json!({"on": true}),
),
"unprotectWorkbook" => Dialog::form(
"unprotectWorkbook",
"Unprotect Workbook",
"review.protectWorkbook",
vec![Password { key: "password", label: "Password:" }],
json!({"on": false}),
),
"unprotectSheet" => Dialog::form(
"unprotectSheet",
"Unprotect Sheet",

View File

@ -1720,7 +1720,7 @@ 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!({}));
crate::backstage::protect_workbook(app);
}
if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() {
app.open_dialog("allowEditRanges", json!({}));

View File

@ -164,8 +164,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert("sheet.unhide", json!({}));
}
ui.separator();
if ui.button("Protect Sheet…").clicked() {
app.open_dialog("protectSheet", json!({}));
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() {
app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({}));
}
if ui.button("Select All Sheets").clicked() {
ui.close();

View File

@ -57,7 +57,7 @@ fn protect_and_unprotect_sheet_mask_the_password() {
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some()));
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password.is_some()));
// Unprotect asks for the password with a masked field too, and it starts hidden again.
h.state_mut().open_dialog("unprotectSheet", json!({}));

View File

@ -185,6 +185,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
}
if let Some(p) = wb_xml.child("workbookProtection") {
wb.protected_structure = p.flag("lockStructure", false);
wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None };
}
let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize;

View File

@ -143,11 +143,9 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
"rowBreaks" => sheet.print.row_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&r| r > 0 && r < MAX_ROWS).collect(),
"colBreaks" => sheet.print.col_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&c| c > 0 && c < MAX_COLS).collect(),
"sheetProtection" if e.flag("sheet", false) => {
if e.attr("password").is_some() || e.attr("hashValue").is_some() {
cx.warn(format!("sheet \"{name}\": the protection password was not imported"));
}
sheet.protection = Some(SheetProtection {
password_hash: None,
password: read_password(e, ""),
legacy_password_hash: None,
select_locked: !e.flag("selectLockedCells", false),
select_unlocked: !e.flag("selectUnlockedCells", false),
format_cells: !e.flag("formatCells", true),
@ -208,6 +206,35 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
Ok(sheet)
}
/// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`,
/// `saltValue`, `spinCount`) or the legacy 16-bit `password`, each name preceded by `prefix`
/// (`workbook` gives `workbookHashValue`…). One that can't be read keeps a hash that never
/// matches, so what it protects stays locked rather than open.
pub(crate) fn read_password(e: &El, prefix: &str) -> Option<PasswordHash> {
let attr = |base: &str| e.attr(&password_attr(prefix, base));
if attr("algorithmName").is_some() || attr("hashValue").is_some() {
return Some(PasswordHash::Iterated {
algorithm: attr("algorithmName").unwrap_or("").to_string(),
hash: attr("hashValue").unwrap_or("").to_string(),
salt: attr("saltValue").unwrap_or("").to_string(),
spin_count: e.attr_u32(&password_attr(prefix, "spinCount")).unwrap_or(0),
});
}
attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) {
Ok(h) => PasswordHash::Legacy(h),
Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 },
})
}
/// `base` preceded by `prefix` in camel case: ("workbook", "hashValue") → `workbookHashValue`.
pub(crate) fn password_attr(prefix: &str, base: &str) -> String {
let mut chars = base.chars();
match chars.next() {
Some(c) if !prefix.is_empty() => format!("{prefix}{}{}", c.to_ascii_uppercase(), chars.as_str()),
_ => base.to_string(),
}
}
/// 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) {
@ -216,20 +243,7 @@ fn read_protected_ranges(e: &El, sheet: &mut Sheet) {
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 password = read_password(p, "");
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) });
}

View File

@ -336,6 +336,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
if !p.select_unlocked {
s.push_str(" selectUnlockedCells=\"1\"");
}
if let Some(h) = &p.password {
s.push_str(&password_attrs(h, ""));
}
s.push_str("/>");
}
s.push_str(&protected_ranges_xml(sheet));
@ -1122,26 +1125,33 @@ fn threaded_xml(sheet: &Sheet, ids: &BTreeMap<CellRef, Vec<String>>, persons: &m
s
}
/// A protection password hash as attributes, each name preceded by `prefix` (see
/// [`crate::sheet_read::read_password`]).
pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String {
let name = |base: &str| crate::sheet_read::password_attr(prefix, base);
match h {
gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")),
gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!(
" {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"",
name("algorithmName"),
esc_attr(algorithm),
name("hashValue"),
esc_attr(hash),
name("saltValue"),
esc_attr(salt),
name("spinCount")
),
}
}
/// 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(h) = &r.password {
s.push_str(&password_attrs(h, ""));
}
if let Some(sd) = &r.security_descriptor {
let _ = write!(s, " securityDescriptor=\"{}\"", esc_attr(sd));

View File

@ -48,7 +48,7 @@ fn shared_formulas_and_value_types() {
#[test]
fn workbook_settings_names_and_states() {
let wb_extra = r#"<workbookPr date1904="1"/><workbookProtection lockStructure="1"/><bookViews><workbookView activeTab="0"/></bookViews>"#;
let wb_extra = r#"<workbookPr date1904="1"/><workbookProtection workbookPassword="83AF" lockStructure="1"/><bookViews><workbookView activeTab="0"/></bookViews>"#;
// Defined names must come after <sheets>; build the workbook by hand.
let wbx = format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">{wb_extra}<sheets><sheet name="A" sheetId="1" r:id="rId1"/><sheet name="B" sheetId="2" state="veryHidden" r:id="rId2"/><sheet name="Chart" sheetId="3" r:id="rId3"/><sheet name="C" sheetId="4" state="hidden" r:id="rId4"/></sheets>
@ -69,6 +69,9 @@ fn workbook_settings_names_and_states() {
assert_eq!(wb.sheets.len(), 3);
assert_eq!(wb.date_system, DateSystem::D1904);
assert!(wb.protected_structure);
// The legacy structure password ("password" hashes to 83AF) is kept and really verifies.
let pw = wb.structure_password.as_ref().unwrap();
assert!(pw.verify("password").unwrap() && !pw.verify("Password").unwrap());
assert_eq!(wb.calc.mode, CalcMode::Manual);
assert!(wb.calc.iterative);
assert_eq!(wb.calc.max_iterations, 50);
@ -220,7 +223,7 @@ fn hyperlinks_comments_tables_validation_filters() {
<printOptions gridLines="1" horizontalCentered="1"/><pageMargins left="0.5" right="0.5" top="1" bottom="1" header="0.4" footer="0.4"/>
<headerFooter><oddHeader>&amp;CTitle</oddHeader><oddFooter>Page &amp;P</oddFooter></headerFooter>
<rowBreaks count="1"><brk id="10" max="16383" man="1"/></rowBreaks>
<sheetProtection sheet="1" formatCells="0" sort="0" password="CC1A"/>
<sheetProtection sheet="1" formatCells="0" sort="0" password="CBEB"/>
<tableParts count="1"><tablePart r:id="rId2"/></tableParts>
<extLst><ext uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"><x14:dataValidations xmlns:x14="x" xmlns:xm="m" count="1"><x14:dataValidation type="list"><x14:formula1><xm:f>Other!$A$1:$A$3</xm:f></x14:formula1><xm:sqref>G1:G4</xm:sqref></x14:dataValidation></x14:dataValidations></ext>
<ext uri="{05C60535-1F16-4fd2-B633-F4F36F0B64E0}"><x14:sparklineGroups xmlns:x14="x" xmlns:xm="m"><x14:sparklineGroup type="column" markers="1"><x14:colorSeries rgb="FF376092"/><x14:sparklines><x14:sparkline><xm:f>Data!A1:E1</xm:f><xm:sqref>F1</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup></x14:sparklineGroups></ext></extLst>"#;
@ -267,7 +270,10 @@ Check this</t></r></text></comment></commentList></comments>"#;
assert_eq!(s.print.row_breaks, vec![10]);
let p = s.protection.as_ref().unwrap();
assert!(p.format_cells && p.sort && !p.insert_rows && p.select_locked);
assert!(rep.warnings.iter().any(|w| w.contains("password")));
// The legacy sheet password is imported (CBEB is the hash of "test") and verifies.
let pw = p.password.as_ref().unwrap();
assert!(pw.verify("test").unwrap() && !pw.verify("Test").unwrap());
assert!(!rep.warnings.iter().any(|w| w.contains("password")), "{:?}", rep.warnings);
assert_eq!(s.sparklines.len(), 1);
assert_eq!(s.sparklines[0].kind, gridcraft_model::SparklineKind::Column);
}

View File

@ -22,6 +22,7 @@ fn sample() -> Workbook {
wb.date_system = DateSystem::D1904;
wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true };
wb.protected_structure = true;
wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef"));
wb.props = DocProps {
title: "T & <x>".into(),
subject: "S".into(),
@ -247,7 +248,14 @@ fn sample() -> Workbook {
(3, FilterCriterion::FillColor(Color::Rgb(0xFF0000))),
],
});
s.protection = Some(SheetProtection { format_cells: true, sort: true, select_locked: true, select_unlocked: true, ..Default::default() });
s.protection = Some(SheetProtection {
format_cells: true,
sort: true,
select_locked: true,
select_unlocked: true,
password: Some(gridcraft_model::PasswordHash::new_sha512("sheet pw", b"fedcba9876543210")),
..Default::default()
});
s.print = PrintSettings {
orientation: Orientation::Landscape,
paper: "A4".into(),
@ -412,6 +420,8 @@ fn full_roundtrip() {
assert_eq!(back.date_system, wb.date_system);
assert_eq!(back.calc, wb.calc);
assert!(back.protected_structure);
assert_eq!(back.structure_password, wb.structure_password);
assert!(back.structure_password.as_ref().unwrap().verify("s3cret").unwrap());
assert_eq!(back.props, wb.props);
assert_eq!(back.theme, wb.theme);
assert_eq!(back.names, wb.names);

View File

@ -276,7 +276,8 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
let mut w = format!("{XML_DECL}<workbook xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "<workbookPr date1904=\"1\"/>" } else { "<workbookPr/>" });
if wb.protected_structure {
w.push_str("<workbookProtection lockStructure=\"1\"/>");
let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default();
let _ = write!(w, "<workbookProtection{pw} lockStructure=\"1\"/>");
}
let _ = write!(w, "<bookViews><workbookView activeTab=\"{active}\"/></bookViews><sheets>{sheets_xml}</sheets>");
w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out));