Normalize entry parsing and display order
This commit is contained in:
@@ -31,7 +31,6 @@ pub enum EntryFieldKind {
|
||||
OtpUri,
|
||||
Field,
|
||||
Note,
|
||||
Blank,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -234,6 +233,7 @@ pub struct EntryDocument {
|
||||
next_id: u64,
|
||||
conflict_token: DocumentConflictToken,
|
||||
session: EditSession,
|
||||
modified: bool,
|
||||
}
|
||||
|
||||
impl EntryDocument {
|
||||
@@ -249,6 +249,7 @@ impl EntryDocument {
|
||||
next_id,
|
||||
conflict_token,
|
||||
session,
|
||||
modified: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +261,13 @@ impl EntryDocument {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
/// Fields in the shared presentation order without changing repository order.
|
||||
pub fn display_fields(&self) -> Vec<&EntryField> {
|
||||
let mut fields = self.fields.iter().collect::<Vec<_>>();
|
||||
fields.sort_by_cached_key(|field| display_key(field));
|
||||
fields
|
||||
}
|
||||
|
||||
pub fn field(&self, id: EntryFieldId) -> Option<&EntryField> {
|
||||
self.fields.iter().find(|field| field.id == id)
|
||||
}
|
||||
@@ -289,7 +297,7 @@ impl EntryDocument {
|
||||
let draft = match field.metadata().kind() {
|
||||
EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?,
|
||||
EntryFieldKind::Password => EntryFieldDraft::line(value)?,
|
||||
EntryFieldKind::Note | EntryFieldKind::Blank => EntryFieldDraft::multiline(value),
|
||||
EntryFieldKind::Note => EntryFieldDraft::multiline(value),
|
||||
EntryFieldKind::Username
|
||||
| EntryFieldKind::Email
|
||||
| EntryFieldKind::Url
|
||||
@@ -329,11 +337,12 @@ impl EntryDocument {
|
||||
id,
|
||||
contents: draft.render(),
|
||||
ending: default_ending.clone(),
|
||||
metadata: blank_metadata(),
|
||||
metadata: empty_note_metadata(),
|
||||
},
|
||||
);
|
||||
normalize_endings(&mut self.fields, &default_ending, final_newline);
|
||||
classify_all(&mut self.fields);
|
||||
self.modified = true;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -342,13 +351,14 @@ impl EntryDocument {
|
||||
id: EntryFieldId,
|
||||
draft: EntryFieldDraft,
|
||||
) -> Result<(), DocumentError> {
|
||||
let field = self
|
||||
let index = self
|
||||
.fields
|
||||
.iter_mut()
|
||||
.find(|field| field.id == id)
|
||||
.iter()
|
||||
.position(|field| field.id == id)
|
||||
.ok_or(DocumentError::UnknownField { id })?;
|
||||
field.contents = draft.render();
|
||||
self.fields[index].contents = draft.render();
|
||||
classify_all(&mut self.fields);
|
||||
self.modified = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -363,6 +373,7 @@ impl EntryDocument {
|
||||
let removed = self.fields.remove(index);
|
||||
normalize_endings(&mut self.fields, &default_ending, final_newline);
|
||||
classify_all(&mut self.fields);
|
||||
self.modified = true;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
@@ -384,10 +395,14 @@ impl EntryDocument {
|
||||
self.fields.insert(index, field);
|
||||
normalize_endings(&mut self.fields, &default_ending, final_newline);
|
||||
classify_all(&mut self.fields);
|
||||
self.modified = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> SecretBytes {
|
||||
if !self.modified {
|
||||
return SecretBytes::new(self.session.plaintext().expose().to_vec());
|
||||
}
|
||||
let capacity = self
|
||||
.fields
|
||||
.iter()
|
||||
@@ -395,7 +410,7 @@ impl EntryDocument {
|
||||
.sum();
|
||||
let mut output = Vec::with_capacity(capacity);
|
||||
for field in &self.fields {
|
||||
output.extend_from_slice(field.contents.expose());
|
||||
append_stable_contents(&mut output, field);
|
||||
output.extend_from_slice(&field.ending);
|
||||
}
|
||||
SecretBytes::new(output)
|
||||
@@ -540,7 +555,7 @@ fn parse_lines(contents: &[u8]) -> Vec<EntryField> {
|
||||
id: EntryFieldId(fields.len() as u64),
|
||||
contents: SecretBytes::new(contents[start..content_end].to_vec()),
|
||||
ending,
|
||||
metadata: blank_metadata(),
|
||||
metadata: empty_note_metadata(),
|
||||
});
|
||||
start = end;
|
||||
}
|
||||
@@ -551,7 +566,12 @@ fn parse_lines(contents: &[u8]) -> Vec<EntryField> {
|
||||
&& logical.last().is_some_and(|previous| {
|
||||
let metadata =
|
||||
classify(logical.len().saturating_sub(1), previous.contents.expose());
|
||||
metadata.name.is_some() && metadata.kind != EntryFieldKind::OtpUri
|
||||
metadata.kind == EntryFieldKind::Note
|
||||
|| (metadata.name.is_some()
|
||||
&& !matches!(
|
||||
metadata.kind,
|
||||
EntryFieldKind::Password | EntryFieldKind::OtpUri
|
||||
))
|
||||
});
|
||||
if continuation {
|
||||
let previous = logical
|
||||
@@ -559,10 +579,16 @@ fn parse_lines(contents: &[u8]) -> Vec<EntryField> {
|
||||
.expect("continuation has a previous field");
|
||||
let mut contents = previous.contents.expose().to_vec();
|
||||
contents.extend_from_slice(&previous.ending);
|
||||
contents.extend_from_slice(field.contents.expose());
|
||||
contents.extend_from_slice(strip_continuation_marker(field.contents.expose()));
|
||||
previous.contents = SecretBytes::new(contents);
|
||||
previous.ending = field.ending;
|
||||
} else {
|
||||
let mut field = field;
|
||||
if classify(logical.len(), field.contents.expose()).kind == EntryFieldKind::Note
|
||||
&& let Some(contents) = field.contents.expose().strip_prefix(b" ")
|
||||
{
|
||||
field.contents = SecretBytes::new(contents.to_vec());
|
||||
}
|
||||
logical.push(field);
|
||||
}
|
||||
}
|
||||
@@ -612,7 +638,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
};
|
||||
}
|
||||
if line.is_empty() {
|
||||
return blank_metadata();
|
||||
return empty_note_metadata();
|
||||
}
|
||||
if line.starts_with(b"otpauth://") {
|
||||
return EntryFieldMetadata {
|
||||
@@ -629,24 +655,22 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
.position(|byte| *byte == b'\n')
|
||||
.unwrap_or(line.len());
|
||||
let first_line = &line[..first_line_end];
|
||||
if let Some(colon) = first_line.iter().position(|byte| *byte == b':') {
|
||||
let raw_name = trim_ascii(&first_line[..colon]);
|
||||
if !raw_name.is_empty()
|
||||
&& let Ok(name) = std::str::from_utf8(raw_name)
|
||||
{
|
||||
let value_start = colon + 1 + usize::from(line.get(colon + 1) == Some(&b' '));
|
||||
let kind = semantic_field_kind(name);
|
||||
return EntryFieldMetadata {
|
||||
kind,
|
||||
sensitivity: field_sensitivity(name, kind),
|
||||
name: Some(name.to_owned()),
|
||||
otp: None,
|
||||
diagnostic: std::str::from_utf8(&line[value_start..])
|
||||
.is_err()
|
||||
.then_some(EntryFieldDiagnostic::NonUtf8Value),
|
||||
value: value_start..line.len(),
|
||||
};
|
||||
}
|
||||
if let Some(colon) = first_line.iter().position(|byte| *byte == b':')
|
||||
&& let Ok(name) = std::str::from_utf8(&first_line[..colon])
|
||||
&& is_field_name(name)
|
||||
{
|
||||
let value_start = colon + 1 + usize::from(line.get(colon + 1) == Some(&b' '));
|
||||
let kind = semantic_field_kind(name);
|
||||
return EntryFieldMetadata {
|
||||
kind,
|
||||
sensitivity: field_sensitivity(name, kind),
|
||||
name: Some(name.to_owned()),
|
||||
otp: None,
|
||||
diagnostic: std::str::from_utf8(&line[value_start..])
|
||||
.is_err()
|
||||
.then_some(EntryFieldDiagnostic::NonUtf8Value),
|
||||
value: value_start..line.len(),
|
||||
};
|
||||
}
|
||||
EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Note,
|
||||
@@ -661,7 +685,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
}
|
||||
|
||||
fn semantic_field_kind(name: &str) -> EntryFieldKind {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"user" | "username" | "login" => EntryFieldKind::Username,
|
||||
"email" | "e-mail" => EntryFieldKind::Email,
|
||||
"url" | "uri" | "website" => EntryFieldKind::Url,
|
||||
@@ -676,15 +700,31 @@ fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity {
|
||||
) {
|
||||
return EntrySensitivity::Ordinary;
|
||||
}
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary,
|
||||
_ => EntrySensitivity::Sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
fn blank_metadata() -> EntryFieldMetadata {
|
||||
fn display_key(field: &EntryField) -> (u8, String) {
|
||||
let metadata = field.metadata();
|
||||
let name = metadata.name().unwrap_or_default().to_lowercase();
|
||||
let priority = match metadata.kind() {
|
||||
EntryFieldKind::Username => 0,
|
||||
EntryFieldKind::Password => 1,
|
||||
EntryFieldKind::Url => 2,
|
||||
EntryFieldKind::OtpUri => 3,
|
||||
EntryFieldKind::Note => 4,
|
||||
_ if matches!(name.as_str(), "note" | "notes") => 4,
|
||||
_ if name == "comments" => 5,
|
||||
EntryFieldKind::Email | EntryFieldKind::Field => 6,
|
||||
};
|
||||
(priority, if priority == 6 { name } else { String::new() })
|
||||
}
|
||||
|
||||
fn empty_note_metadata() -> EntryFieldMetadata {
|
||||
EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Blank,
|
||||
kind: EntryFieldKind::Note,
|
||||
sensitivity: EntrySensitivity::Empty,
|
||||
name: None,
|
||||
otp: None,
|
||||
@@ -693,16 +733,6 @@ fn blank_metadata() -> EntryFieldMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(mut value: &[u8]) -> &[u8] {
|
||||
while value.first().is_some_and(u8::is_ascii_whitespace) {
|
||||
value = &value[1..];
|
||||
}
|
||||
while value.last().is_some_and(u8::is_ascii_whitespace) {
|
||||
value = &value[..value.len() - 1];
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn validate_line(value: &[u8]) -> Result<(), DocumentError> {
|
||||
if value.iter().any(|byte| matches!(byte, b'\r' | b'\n')) {
|
||||
Err(DocumentError::LineBreak)
|
||||
@@ -712,13 +742,33 @@ fn validate_line(value: &[u8]) -> Result<(), DocumentError> {
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> Result<(), DocumentError> {
|
||||
if name.trim().is_empty() || name.contains([':', '\r', '\n']) || name.trim() != name {
|
||||
if !is_field_name(name) {
|
||||
Err(DocumentError::InvalidFieldName)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_field_name(name: &str) -> bool {
|
||||
!name.is_empty() && !name.contains([':', '\r', '\n']) && !name.chars().any(char::is_whitespace)
|
||||
}
|
||||
|
||||
fn strip_continuation_marker(contents: &[u8]) -> &[u8] {
|
||||
contents.strip_prefix(b" ").unwrap_or(contents)
|
||||
}
|
||||
|
||||
fn append_stable_contents(output: &mut Vec<u8>, field: &EntryField) {
|
||||
if field.metadata().kind() == EntryFieldKind::Note {
|
||||
output.push(b' ');
|
||||
}
|
||||
for byte in field.contents.expose() {
|
||||
output.push(*byte);
|
||||
if *byte == b'\n' {
|
||||
output.push(b' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_endings(fields: &mut [EntryField], default: &[u8], final_newline: bool) {
|
||||
let last = fields.len().saturating_sub(1);
|
||||
for (index, field) in fields.iter_mut().enumerate() {
|
||||
|
||||
Reference in New Issue
Block a user