use crate::{ArchiveError, ArchiveResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
pub const LTFS_FORMAT_VERSION: &str = "2.5.0";
pub const LTFS_MIN_SUPPORTED_VERSION: &str = "2.0.0";
pub const LTFS_XML_NAMESPACE: &str = "http://www.ibm.com/ltfs";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LtfsVolumeLabel {
pub format_version: String,
pub volume_uuid: String,
pub block_size: u64,
pub compression: bool,
pub format_time: DateTime<Utc>,
pub cartridge_label: String,
pub media_type: String,
}
impl LtfsVolumeLabel {
#[must_use]
pub fn new(
volume_uuid: impl Into<String>,
cartridge_label: impl Into<String>,
media_type: impl Into<String>,
block_size: u64,
compression: bool,
) -> Self {
Self {
format_version: LTFS_FORMAT_VERSION.to_string(),
volume_uuid: volume_uuid.into(),
cartridge_label: cartridge_label.into(),
media_type: media_type.into(),
block_size,
compression,
format_time: Utc::now(),
}
}
#[must_use]
pub fn is_version_supported(&self) -> bool {
self.format_version.starts_with("2.")
}
#[must_use]
pub fn to_xml(&self) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<ltfslabel version="{ver}" xmlns="{ns}">
<creator>OxiMedia LTFS</creator>
<formattime>{fmt}</formattime>
<volumeuuid>{uuid}</volumeuuid>
<blocksize>{bs}</blocksize>
<compression>{comp}</compression>
<cartridelabel>{cl}</cartridelabel>
<mediatype>{mt}</mediatype>
</ltfslabel>"#,
ver = self.format_version,
ns = LTFS_XML_NAMESPACE,
fmt = self.format_time.to_rfc3339(),
uuid = self.volume_uuid,
bs = self.block_size,
comp = self.compression,
cl = self.cartridge_label,
mt = self.media_type,
)
}
}
impl fmt::Display for LtfsVolumeLabel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"LtfsLabel[uuid={} label={} media={} block={}B]",
self.volume_uuid, self.cartridge_label, self.media_type, self.block_size
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LtfsExtent {
pub partition: u8,
pub start_block: u64,
pub byte_offset: u64,
pub byte_count: u64,
pub file_offset: u64,
}
impl LtfsExtent {
#[must_use]
pub fn new(
partition: u8,
start_block: u64,
byte_offset: u64,
byte_count: u64,
file_offset: u64,
) -> Self {
Self {
partition,
start_block,
byte_offset,
byte_count,
file_offset,
}
}
#[must_use]
pub fn end_block_estimate(&self, block_size: u64) -> u64 {
if block_size == 0 {
return self.start_block;
}
let total_bytes = self.byte_offset + self.byte_count;
let blocks = total_bytes.div_ceil(block_size);
self.start_block + blocks
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LtfsFileNode {
pub name: String,
pub length: u64,
pub creation_time: DateTime<Utc>,
pub modification_time: DateTime<Utc>,
pub access_time: DateTime<Utc>,
pub permissions: u32,
pub extents: Vec<LtfsExtent>,
pub extended_attributes: HashMap<String, String>,
pub checksum: Option<String>,
}
impl LtfsFileNode {
#[must_use]
pub fn new(name: impl Into<String>, length: u64) -> Self {
let now = Utc::now();
Self {
name: name.into(),
length,
creation_time: now,
modification_time: now,
access_time: now,
permissions: 0o644,
extents: Vec::new(),
extended_attributes: HashMap::new(),
checksum: None,
}
}
pub fn add_extent(&mut self, extent: LtfsExtent) {
self.extents.push(extent);
}
pub fn set_checksum(&mut self, checksum: impl Into<String>) {
self.checksum = Some(checksum.into());
}
#[must_use]
pub fn extent_total_bytes(&self) -> u64 {
self.extents
.iter()
.map(|e| e.byte_count)
.fold(0u64, |acc, n| acc.saturating_add(n))
}
#[must_use]
pub fn extents_consistent(&self) -> bool {
self.extent_total_bytes() == self.length
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LtfsDirNode {
pub name: String,
pub creation_time: DateTime<Utc>,
pub modification_time: DateTime<Utc>,
pub files: Vec<LtfsFileNode>,
pub subdirs: Vec<LtfsDirNode>,
}
impl LtfsDirNode {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
let now = Utc::now();
Self {
name: name.into(),
creation_time: now,
modification_time: now,
files: Vec::new(),
subdirs: Vec::new(),
}
}
pub fn add_file(&mut self, file: LtfsFileNode) {
self.files.push(file);
}
pub fn add_subdir(&mut self, dir: LtfsDirNode) {
self.subdirs.push(dir);
}
#[must_use]
pub fn file_count(&self) -> usize {
let own = self.files.len();
let children: usize = self.subdirs.iter().map(|d| d.file_count()).sum();
own + children
}
#[must_use]
pub fn total_logical_bytes(&self) -> u64 {
let own: u64 = self
.files
.iter()
.map(|f| f.length)
.fold(0u64, |acc, n| acc.saturating_add(n));
let children: u64 = self
.subdirs
.iter()
.map(|d| d.total_logical_bytes())
.fold(0u64, |acc, n| acc.saturating_add(n));
own.saturating_add(children)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LtfsIndex {
pub format_version: String,
pub volume_uuid: String,
pub generation: u64,
pub created_at: DateTime<Utc>,
pub root: LtfsDirNode,
pub previous_generation_offset: Option<u64>,
}
impl LtfsIndex {
#[must_use]
pub fn new(volume_uuid: impl Into<String>) -> Self {
Self {
format_version: LTFS_FORMAT_VERSION.to_string(),
volume_uuid: volume_uuid.into(),
generation: 1,
created_at: Utc::now(),
root: LtfsDirNode::new(""),
previous_generation_offset: None,
}
}
pub fn bump_generation(&mut self) {
self.generation = self.generation.saturating_add(1);
self.created_at = Utc::now();
}
#[must_use]
pub fn total_file_count(&self) -> usize {
self.root.file_count()
}
#[must_use]
pub fn total_logical_bytes(&self) -> u64 {
self.root.total_logical_bytes()
}
pub fn validate(&self) -> ArchiveResult<()> {
if !self.format_version.starts_with("2.") {
return Err(ArchiveError::Validation(format!(
"unsupported LTFS format version: {}",
self.format_version
)));
}
if self.volume_uuid.is_empty() {
return Err(ArchiveError::Validation(
"LTFS index has empty volume UUID".to_string(),
));
}
if self.generation == 0 {
return Err(ArchiveError::Validation(
"LTFS index generation must be >= 1".to_string(),
));
}
Self::validate_dir(&self.root)?;
Ok(())
}
fn validate_dir(dir: &LtfsDirNode) -> ArchiveResult<()> {
for file in &dir.files {
if !file.extents.is_empty() && !file.extents_consistent() {
return Err(ArchiveError::Validation(format!(
"LTFS file '{}' extent bytes ({}) != declared length ({})",
file.name,
file.extent_total_bytes(),
file.length
)));
}
}
for sub in &dir.subdirs {
Self::validate_dir(sub)?;
}
Ok(())
}
}
pub struct LtfsIndexBuilder {
index: LtfsIndex,
}
impl LtfsIndexBuilder {
#[must_use]
pub fn new(volume_uuid: impl Into<String>) -> Self {
Self {
index: LtfsIndex::new(volume_uuid),
}
}
#[must_use]
pub fn generation(mut self, gen: u64) -> Self {
self.index.generation = gen;
self
}
#[must_use]
pub fn add_file(
mut self,
_dir: &str,
name: &str,
length: u64,
extents: Vec<LtfsExtent>,
) -> Self {
let mut file = LtfsFileNode::new(name, length);
for e in extents {
file.add_extent(e);
}
self.index.root.add_file(file);
self
}
#[must_use]
pub fn add_subdir(mut self, dir: LtfsDirNode) -> Self {
self.index.root.add_subdir(dir);
self
}
#[must_use]
pub fn previous_generation_offset(mut self, offset: u64) -> Self {
self.index.previous_generation_offset = Some(offset);
self
}
#[must_use]
pub fn build(self) -> LtfsIndex {
self.index
}
}
pub fn serialize_index(index: &LtfsIndex) -> ArchiveResult<String> {
serde_json::to_string(index).map_err(|e| ArchiveError::Validation(e.to_string()))
}
pub fn deserialize_index(json: &str) -> ArchiveResult<LtfsIndex> {
serde_json::from_str(json).map_err(|e| ArchiveError::Validation(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_label() -> LtfsVolumeLabel {
LtfsVolumeLabel::new(
"550e8400-e29b-41d4-a716-446655440000",
"LTO9-TAPE-001",
"LTO-9",
524_288,
true,
)
}
#[test]
fn test_label_version_supported() {
let label = make_label();
assert!(label.is_version_supported());
}
#[test]
fn test_label_unsupported_version() {
let mut label = make_label();
label.format_version = "1.0.0".to_string();
assert!(!label.is_version_supported());
}
#[test]
fn test_label_to_xml_contains_uuid() {
let label = make_label();
let xml = label.to_xml();
assert!(xml.contains("550e8400-e29b-41d4-a716-446655440000"));
assert!(xml.contains("ltfslabel"));
}
#[test]
fn test_label_display() {
let label = make_label();
let s = label.to_string();
assert!(s.contains("LTO9-TAPE-001"));
}
#[test]
fn test_extent_end_block_estimate() {
let extent = LtfsExtent::new(1, 10, 0, 1_048_576, 0);
assert_eq!(extent.end_block_estimate(524_288), 12);
}
#[test]
fn test_extent_zero_block_size_no_panic() {
let extent = LtfsExtent::new(1, 5, 0, 100, 0);
assert_eq!(extent.end_block_estimate(0), 5); }
#[test]
fn test_file_node_extents_consistent() {
let mut file = LtfsFileNode::new("test.mkv", 1000);
file.add_extent(LtfsExtent::new(1, 0, 0, 600, 0));
file.add_extent(LtfsExtent::new(1, 1, 0, 400, 600));
assert!(file.extents_consistent());
}
#[test]
fn test_file_node_extents_inconsistent() {
let mut file = LtfsFileNode::new("test.mkv", 1000);
file.add_extent(LtfsExtent::new(1, 0, 0, 500, 0)); assert!(!file.extents_consistent());
}
#[test]
fn test_dir_node_file_count_recursive() {
let mut root = LtfsDirNode::new("root");
root.add_file(LtfsFileNode::new("a.mkv", 100));
let mut sub = LtfsDirNode::new("sub");
sub.add_file(LtfsFileNode::new("b.mkv", 200));
sub.add_file(LtfsFileNode::new("c.mkv", 300));
root.add_subdir(sub);
assert_eq!(root.file_count(), 3);
}
#[test]
fn test_dir_node_total_logical_bytes() {
let mut root = LtfsDirNode::new("root");
root.add_file(LtfsFileNode::new("a.mkv", 1_000_000));
let mut sub = LtfsDirNode::new("sub");
sub.add_file(LtfsFileNode::new("b.mkv", 2_000_000));
root.add_subdir(sub);
assert_eq!(root.total_logical_bytes(), 3_000_000);
}
#[test]
fn test_index_validate_ok() {
let mut index = LtfsIndex::new("vol-uuid-0001");
let mut file = LtfsFileNode::new("video.mkv", 500);
file.add_extent(LtfsExtent::new(1, 0, 0, 500, 0));
index.root.add_file(file);
assert!(index.validate().is_ok());
}
#[test]
fn test_index_validate_bad_version() {
let mut index = LtfsIndex::new("vol-uuid-0002");
index.format_version = "1.0.0".to_string();
assert!(index.validate().is_err());
}
#[test]
fn test_index_validate_empty_uuid() {
let index = LtfsIndex::new("");
assert!(index.validate().is_err());
}
#[test]
fn test_index_validate_zero_generation() {
let mut index = LtfsIndex::new("vol-uuid-0003");
index.generation = 0;
assert!(index.validate().is_err());
}
#[test]
fn test_index_validate_inconsistent_extents() {
let mut index = LtfsIndex::new("vol-uuid-0004");
let mut file = LtfsFileNode::new("bad.mkv", 1000);
file.add_extent(LtfsExtent::new(1, 0, 0, 500, 0)); index.root.add_file(file);
assert!(index.validate().is_err());
}
#[test]
fn test_builder_creates_correct_index() {
let index = LtfsIndexBuilder::new("builder-vol-uuid")
.generation(5)
.add_file(
"root",
"archive.tar",
2_000,
vec![LtfsExtent::new(1, 0, 0, 2_000, 0)],
)
.build();
assert_eq!(index.generation, 5);
assert_eq!(index.total_file_count(), 1);
assert_eq!(index.total_logical_bytes(), 2_000);
}
#[test]
fn test_serialize_deserialize_roundtrip() {
let mut index = LtfsIndex::new("roundtrip-vol-uuid");
index
.root
.add_file(LtfsFileNode::new("film.mkv", 8_000_000_000));
let json = serialize_index(&index).expect("serialize should succeed");
let restored = deserialize_index(&json).expect("deserialize should succeed");
assert_eq!(restored.volume_uuid, index.volume_uuid);
assert_eq!(restored.total_file_count(), 1);
}
#[test]
fn test_bump_generation_increments() {
let mut index = LtfsIndex::new("gen-test-uuid");
assert_eq!(index.generation, 1);
index.bump_generation();
assert_eq!(index.generation, 2);
index.bump_generation();
assert_eq!(index.generation, 3);
}
#[test]
fn test_builder_with_subdir() {
let mut subdir = LtfsDirNode::new("dailies");
subdir.add_file(LtfsFileNode::new("day01.mkv", 500));
subdir.add_file(LtfsFileNode::new("day02.mkv", 600));
let index = LtfsIndexBuilder::new("subdir-vol-uuid")
.add_subdir(subdir)
.build();
assert_eq!(index.total_file_count(), 2);
}
#[test]
fn test_label_xml_contains_media_type() {
let label = make_label();
let xml = label.to_xml();
assert!(xml.contains("LTO-9"));
}
}