sdocx 1.1.0

Reverse-engineered tooling and SDK for converting Samsung Notes (.sdocx) files
Documentation
#[allow(dead_code)]
mod support;

use sdocx::{Error, ParseLimits, parse_stored_page_bytes};

fn string(value: &str) -> Vec<u8> {
    let units: Vec<_> = value.encode_utf16().collect();
    let mut bytes = (units.len() as u16).to_le_bytes().to_vec();
    bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes()));
    bytes
}

fn header(
    offset: usize,
    number: u32,
    properties: &[u8],
    fields: &[u8],
    fixed_extra: &[u8],
    flexible: &[u8],
) -> Vec<u8> {
    let mut bytes = vec![0; 8];
    bytes.push(properties.len() as u8);
    bytes.extend(properties);
    bytes.push(fields.len() as u8);
    bytes.extend(fields);
    bytes.extend(number.to_le_bytes());
    bytes.extend(fixed_extra);
    let flexible_offset = (offset + bytes.len()) as u32;
    bytes[4..8].copy_from_slice(&flexible_offset.to_le_bytes());
    bytes.extend(flexible);
    let size = bytes.len() as u32;
    bytes[..4].copy_from_slice(&size.to_le_bytes());
    bytes
}

fn page(count: usize, mut make_header: impl FnMut(usize, usize) -> Vec<u8>) -> Vec<u8> {
    let mut bytes = support::page(&[vec![]], 0, &[]);
    let layer_offset = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize;
    bytes.truncate(layer_offset);
    bytes.extend((count as u16).to_le_bytes());
    bytes.extend(0_u16.to_le_bytes());
    for index in 0..count {
        bytes.extend(make_header(bytes.len(), index));
        bytes.extend(0_u32.to_le_bytes());
        bytes.extend([0xbb; 32]);
    }
    bytes.extend([0xcc; 32]);
    bytes.extend(b"Page for SAMSUNG S-Pen SDK");
    bytes
}

fn complete_fields() -> Vec<u8> {
    let mut fields = vec![129];
    fields.extend(0x81234567_u32.to_le_bytes());
    fields.extend(string("Ink 🖊"));
    fields.extend(string("layer-42"));
    fields.extend((-123456789_i64).to_le_bytes());
    fields.extend(812_u32.to_le_bytes());
    fields.extend(20_u32.to_le_bytes());
    fields.extend([0x61; 20]);
    fields
}

#[test]
fn decodes_native_layer_identity_flags_and_style_in_mask_order() {
    let bytes = page(1, |offset, _| {
        header(offset, 42, &[0x1f], &[0x7f], &[], &complete_fields())
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    let layer = &parsed.layers.layers[0];
    let metadata = layer.metadata(&bytes).unwrap();
    assert_eq!(metadata.number, 42);
    assert!(!metadata.visible);
    assert!(metadata.event_forwardable);
    assert!(metadata.locked);
    assert!(metadata.alpha_locked);
    assert!(metadata.shadow_visible);
    assert_eq!(metadata.transparency, Some(129));
    assert_eq!(metadata.background_color, Some(0x81234567));
    assert_eq!(metadata.name.as_deref(), Some("Ink 🖊"));
    assert_eq!(metadata.uuid.as_deref(), Some("layer-42"));
    assert_eq!(metadata.modified_time, Some(-123456789));
    assert_eq!(metadata.thumbnail_media_id, Some(812));
    assert_eq!(
        metadata.shadow_effect.as_deref(),
        Some([0x61; 20].as_slice())
    );
    assert!(metadata.fixed_trailing_data.is_empty());
    assert!(metadata.flexible_trailing_data.is_empty());
}

#[test]
fn absolute_offsets_and_wide_masks_preserve_multiple_layer_boundaries() {
    let bytes = page(2, |offset, index| {
        let flexible = [
            string(&format!("layer-{index}")),
            (index as i64).to_le_bytes().to_vec(),
            vec![0x91, 0x92],
        ]
        .concat();
        header(
            offset,
            42 + index as u32,
            &[2, 0, 0, 0, 1],
            &[0x18, 0, 0, 0, 2],
            &[0x81; 7],
            &flexible,
        )
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    for (index, layer) in parsed.layers.layers.iter().enumerate() {
        let metadata = layer.metadata(&bytes).unwrap();
        assert_eq!(metadata.number, 42 + index as u32);
        assert_eq!(
            metadata.uuid.as_deref(),
            Some(format!("layer-{index}").as_str())
        );
        assert_eq!(metadata.modified_time, Some(index as i64));
        assert_eq!(metadata.property_mask, [2, 0, 0, 0, 1]);
        assert_eq!(metadata.field_mask, [0x18, 0, 0, 0, 2]);
        assert_eq!(metadata.fixed_trailing_data, [0x81; 7]);
        assert_eq!(metadata.flexible_trailing_data, [0x91, 0x92]);
        assert!(metadata.visible);
    }
}

#[test]
fn absent_flexible_fields_remain_absent_with_a_zero_offset() {
    let bytes = page(1, |offset, _| {
        let mut header = header(offset, 42, &[0], &[0], &[], &[]);
        header[4..8].fill(0);
        header
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    let metadata = parsed.layers.layers[0].metadata(&bytes).unwrap();
    assert!(metadata.visible);
    assert!(!metadata.event_forwardable);
    assert_eq!(metadata.transparency, None);
    assert_eq!(metadata.name, None);
    assert_eq!(metadata.uuid, None);
    assert_eq!(metadata.modified_time, None);
    assert_eq!(metadata.shadow_effect, None);
}

#[test]
fn metadata_offsets_cannot_point_into_masks_objects_or_other_layers() {
    let bytes = page(2, |offset, index| {
        header(
            offset,
            index as u32,
            &[2],
            &[0x18],
            &[],
            &[string("layer"), 123_i64.to_le_bytes().to_vec()].concat(),
        )
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    let layer = &parsed.layers.layers[0];
    let offset = layer.header_offset;
    for invalid_offset in [
        0,
        offset - 1,
        offset + 11,
        offset + 15,
        offset + layer.header_size + 1,
        parsed.layers.layers[1].header_offset,
    ] {
        let mut invalid = bytes.clone();
        invalid[offset + 4..offset + 8].copy_from_slice(&(invalid_offset as u32).to_le_bytes());
        assert!(layer.metadata(&invalid).is_err(), "offset {invalid_offset}");
    }
}

#[test]
fn truncated_known_fields_cannot_borrow_from_the_object_count_or_hash() {
    let fields = complete_fields();
    for length in 0..fields.len() {
        let bytes = page(1, |offset, _| {
            header(offset, 42, &[2], &[0x7f], &[], &fields[..length])
        });
        let parsed = parse_stored_page_bytes(&bytes).unwrap();
        assert!(
            parsed.layers.layers[0].metadata(&bytes).is_err(),
            "length {length}"
        );
    }
}

#[test]
fn strings_and_sized_effects_obey_bounds_before_allocation() {
    let bytes = page(1, |offset, _| {
        header(offset, 42, &[2], &[0x7f], &[], &complete_fields())
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    let limits = ParseLimits {
        max_text_characters: 2,
        ..ParseLimits::default()
    };
    assert!(matches!(
        parsed.layers.layers[0].metadata_with_limits(&bytes, &limits),
        Err(Error::LimitExceeded {
            resource: "text characters",
            ..
        })
    ));

    let bytes = page(1, |offset, _| {
        header(offset, 42, &[2], &[0x40], &[], &u32::MAX.to_le_bytes())
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    assert!(parsed.layers.layers[0].metadata(&bytes).is_err());

    let bytes = page(1, |offset, _| {
        header(offset, 42, &[2], &[4], &[], &[1, 0, 0, 0xdc])
    });
    let parsed = parse_stored_page_bytes(&bytes).unwrap();
    assert!(
        parsed.layers.layers[0]
            .metadata(&bytes)
            .unwrap_err()
            .to_string()
            .contains("invalid UTF-16")
    );
}