Split spec in reader/writer/general
This commit is contained in:
parent
0cd915f185
commit
91cbe91d62
6 changed files with 181 additions and 169 deletions
|
@ -14,6 +14,8 @@ pub use types::ZipFile;
|
||||||
|
|
||||||
mod util;
|
mod util;
|
||||||
mod spec;
|
mod spec;
|
||||||
|
mod reader_spec;
|
||||||
|
mod writer_spec;
|
||||||
mod crc32;
|
mod crc32;
|
||||||
mod reader;
|
mod reader;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
use spec;
|
|
||||||
use crc32::Crc32Reader;
|
use crc32::Crc32Reader;
|
||||||
use types::ZipFile;
|
use types::ZipFile;
|
||||||
use compression;
|
use compression;
|
||||||
|
use spec;
|
||||||
|
use reader_spec;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::{IoResult, IoError};
|
use std::io::{IoResult, IoError};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
@ -64,7 +65,7 @@ impl<T: Reader+Seek> ZipReader<T>
|
||||||
try!(reader.seek(directory_start, io::SeekSet));
|
try!(reader.seek(directory_start, io::SeekSet));
|
||||||
for i in range(0, number_of_files)
|
for i in range(0, number_of_files)
|
||||||
{
|
{
|
||||||
files.push(try!(spec::central_header_to_zip_file(&mut reader)));
|
files.push(try!(reader_spec::central_header_to_zip_file(&mut reader)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ZipReader { inner: RefCell::new(reader), files: files })
|
Ok(ZipReader { inner: RefCell::new(reader), files: files })
|
||||||
|
|
110
src/reader_spec.rs
Normal file
110
src/reader_spec.rs
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
use std::io;
|
||||||
|
use std::io::{IoResult, IoError};
|
||||||
|
use compression;
|
||||||
|
use types::ZipFile;
|
||||||
|
use spec;
|
||||||
|
use util;
|
||||||
|
|
||||||
|
pub fn central_header_to_zip_file<R: Reader+Seek>(reader: &mut R) -> IoResult<ZipFile>
|
||||||
|
{
|
||||||
|
// Parse central header
|
||||||
|
let signature = try!(reader.read_le_u32());
|
||||||
|
if signature != spec::CENTRAL_DIRECTORY_HEADER_SIGNATURE
|
||||||
|
{
|
||||||
|
return Err(IoError {
|
||||||
|
kind: io::MismatchedFileTypeForOperation,
|
||||||
|
desc: "Invalid central directory header",
|
||||||
|
detail: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
try!(reader.read_le_u16());
|
||||||
|
try!(reader.read_le_u16());
|
||||||
|
let flags = try!(reader.read_le_u16());
|
||||||
|
let encrypted = flags & 1 == 1;
|
||||||
|
let is_utf8 = flags & (1 << 11) != 0;
|
||||||
|
let compression_method = try!(reader.read_le_u16());
|
||||||
|
let last_mod_time = try!(reader.read_le_u16());
|
||||||
|
let last_mod_date = try!(reader.read_le_u16());
|
||||||
|
let crc32 = try!(reader.read_le_u32());
|
||||||
|
let compressed_size = try!(reader.read_le_u32());
|
||||||
|
let uncompressed_size = try!(reader.read_le_u32());
|
||||||
|
let file_name_length = try!(reader.read_le_u16()) as uint;
|
||||||
|
let extra_field_length = try!(reader.read_le_u16()) as uint;
|
||||||
|
let file_comment_length = try!(reader.read_le_u16()) as uint;
|
||||||
|
try!(reader.read_le_u16());
|
||||||
|
try!(reader.read_le_u16());
|
||||||
|
try!(reader.read_le_u32());
|
||||||
|
let offset = try!(reader.read_le_u32()) as i64;
|
||||||
|
let file_name_raw = try!(reader.read_exact(file_name_length));
|
||||||
|
let extra_field = try!(reader.read_exact(extra_field_length));
|
||||||
|
let file_comment_raw = try!(reader.read_exact(file_comment_length));
|
||||||
|
|
||||||
|
let file_name = match is_utf8
|
||||||
|
{
|
||||||
|
true => String::from_utf8_lossy(file_name_raw.as_slice()).into_string(),
|
||||||
|
false => ::cp437::to_string(file_name_raw.as_slice()),
|
||||||
|
};
|
||||||
|
let file_comment = match is_utf8
|
||||||
|
{
|
||||||
|
true => String::from_utf8_lossy(file_comment_raw.as_slice()).into_string(),
|
||||||
|
false => ::cp437::to_string(file_comment_raw.as_slice()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remember end of central header
|
||||||
|
let return_position = try!(reader.tell()) as i64;
|
||||||
|
|
||||||
|
// Parse local header
|
||||||
|
try!(reader.seek(offset, io::SeekSet));
|
||||||
|
let signature = try!(reader.read_le_u32());
|
||||||
|
if signature != spec::LOCAL_FILE_HEADER_SIGNATURE
|
||||||
|
{
|
||||||
|
return Err(IoError {
|
||||||
|
kind: io::MismatchedFileTypeForOperation,
|
||||||
|
desc: "Invalid local file header",
|
||||||
|
detail: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
try!(reader.seek(22, io::SeekCur));
|
||||||
|
let file_name_length = try!(reader.read_le_u16()) as u64;
|
||||||
|
let extra_field_length = try!(reader.read_le_u16()) as u64;
|
||||||
|
let magic_and_header = 4 + 22 + 2 + 2;
|
||||||
|
let data_start = offset as u64 + magic_and_header + file_name_length + extra_field_length;
|
||||||
|
|
||||||
|
// Construct the result
|
||||||
|
let mut result = ZipFile
|
||||||
|
{
|
||||||
|
encrypted: encrypted,
|
||||||
|
compression_method: FromPrimitive::from_u16(compression_method).unwrap_or(compression::Unknown),
|
||||||
|
last_modified_time: util::msdos_datetime_to_tm(last_mod_time, last_mod_date),
|
||||||
|
crc32: crc32,
|
||||||
|
compressed_size: compressed_size as u64,
|
||||||
|
uncompressed_size: uncompressed_size as u64,
|
||||||
|
file_name: file_name,
|
||||||
|
file_comment: file_comment,
|
||||||
|
header_start: offset as u64,
|
||||||
|
data_start: data_start,
|
||||||
|
};
|
||||||
|
|
||||||
|
try!(parse_extra_field(&mut result, extra_field.as_slice()));
|
||||||
|
|
||||||
|
// Go back after the central header
|
||||||
|
try!(reader.seek(return_position, io::SeekSet));
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_extra_field(_file: &mut ZipFile, data: &[u8]) -> IoResult<()>
|
||||||
|
{
|
||||||
|
let mut reader = io::BufReader::new(data);
|
||||||
|
while !reader.eof()
|
||||||
|
{
|
||||||
|
let kind = try!(reader.read_le_u16());
|
||||||
|
let len = try!(reader.read_le_u16());
|
||||||
|
debug!("Parsing extra block {:04x}", kind);
|
||||||
|
match kind
|
||||||
|
{
|
||||||
|
_ => try!(reader.seek(len as i64, io::SeekCur)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
166
src/spec.rs
166
src/spec.rs
|
@ -1,173 +1,11 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::{IoResult, IoError};
|
use std::io::{IoResult, IoError};
|
||||||
use std::iter::range_step_inclusive;
|
use std::iter::range_step_inclusive;
|
||||||
use compression;
|
|
||||||
use types::ZipFile;
|
|
||||||
use util;
|
|
||||||
|
|
||||||
static LOCAL_FILE_HEADER_SIGNATURE : u32 = 0x04034b50;
|
pub static LOCAL_FILE_HEADER_SIGNATURE : u32 = 0x04034b50;
|
||||||
static CENTRAL_DIRECTORY_HEADER_SIGNATURE : u32 = 0x02014b50;
|
pub static CENTRAL_DIRECTORY_HEADER_SIGNATURE : u32 = 0x02014b50;
|
||||||
static CENTRAL_DIRECTORY_END_SIGNATURE : u32 = 0x06054b50;
|
static CENTRAL_DIRECTORY_END_SIGNATURE : u32 = 0x06054b50;
|
||||||
|
|
||||||
pub fn central_header_to_zip_file<R: Reader+Seek>(reader: &mut R) -> IoResult<ZipFile>
|
|
||||||
{
|
|
||||||
// Parse central header
|
|
||||||
let signature = try!(reader.read_le_u32());
|
|
||||||
if signature != CENTRAL_DIRECTORY_HEADER_SIGNATURE
|
|
||||||
{
|
|
||||||
return Err(IoError {
|
|
||||||
kind: io::MismatchedFileTypeForOperation,
|
|
||||||
desc: "Invalid central directory header",
|
|
||||||
detail: None })
|
|
||||||
}
|
|
||||||
|
|
||||||
try!(reader.read_le_u16());
|
|
||||||
try!(reader.read_le_u16());
|
|
||||||
let flags = try!(reader.read_le_u16());
|
|
||||||
let encrypted = flags & 1 == 1;
|
|
||||||
let is_utf8 = flags & (1 << 11) != 0;
|
|
||||||
let compression_method = try!(reader.read_le_u16());
|
|
||||||
let last_mod_time = try!(reader.read_le_u16());
|
|
||||||
let last_mod_date = try!(reader.read_le_u16());
|
|
||||||
let crc32 = try!(reader.read_le_u32());
|
|
||||||
let compressed_size = try!(reader.read_le_u32());
|
|
||||||
let uncompressed_size = try!(reader.read_le_u32());
|
|
||||||
let file_name_length = try!(reader.read_le_u16()) as uint;
|
|
||||||
let extra_field_length = try!(reader.read_le_u16()) as uint;
|
|
||||||
let file_comment_length = try!(reader.read_le_u16()) as uint;
|
|
||||||
try!(reader.read_le_u16());
|
|
||||||
try!(reader.read_le_u16());
|
|
||||||
try!(reader.read_le_u32());
|
|
||||||
let offset = try!(reader.read_le_u32()) as i64;
|
|
||||||
let file_name_raw = try!(reader.read_exact(file_name_length));
|
|
||||||
let extra_field = try!(reader.read_exact(extra_field_length));
|
|
||||||
let file_comment_raw = try!(reader.read_exact(file_comment_length));
|
|
||||||
|
|
||||||
let file_name = match is_utf8
|
|
||||||
{
|
|
||||||
true => String::from_utf8_lossy(file_name_raw.as_slice()).into_string(),
|
|
||||||
false => ::cp437::to_string(file_name_raw.as_slice()),
|
|
||||||
};
|
|
||||||
let file_comment = match is_utf8
|
|
||||||
{
|
|
||||||
true => String::from_utf8_lossy(file_comment_raw.as_slice()).into_string(),
|
|
||||||
false => ::cp437::to_string(file_comment_raw.as_slice()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remember end of central header
|
|
||||||
let return_position = try!(reader.tell()) as i64;
|
|
||||||
|
|
||||||
// Parse local header
|
|
||||||
try!(reader.seek(offset, io::SeekSet));
|
|
||||||
let signature = try!(reader.read_le_u32());
|
|
||||||
if signature != LOCAL_FILE_HEADER_SIGNATURE
|
|
||||||
{
|
|
||||||
return Err(IoError {
|
|
||||||
kind: io::MismatchedFileTypeForOperation,
|
|
||||||
desc: "Invalid local file header",
|
|
||||||
detail: None })
|
|
||||||
}
|
|
||||||
|
|
||||||
try!(reader.seek(22, io::SeekCur));
|
|
||||||
let file_name_length = try!(reader.read_le_u16()) as u64;
|
|
||||||
let extra_field_length = try!(reader.read_le_u16()) as u64;
|
|
||||||
let magic_and_header = 4 + 22 + 2 + 2;
|
|
||||||
let data_start = offset as u64 + magic_and_header + file_name_length + extra_field_length;
|
|
||||||
|
|
||||||
// Construct the result
|
|
||||||
let mut result = ZipFile
|
|
||||||
{
|
|
||||||
encrypted: encrypted,
|
|
||||||
compression_method: FromPrimitive::from_u16(compression_method).unwrap_or(compression::Unknown),
|
|
||||||
last_modified_time: util::msdos_datetime_to_tm(last_mod_time, last_mod_date),
|
|
||||||
crc32: crc32,
|
|
||||||
compressed_size: compressed_size as u64,
|
|
||||||
uncompressed_size: uncompressed_size as u64,
|
|
||||||
file_name: file_name,
|
|
||||||
file_comment: file_comment,
|
|
||||||
header_start: offset as u64,
|
|
||||||
data_start: data_start,
|
|
||||||
};
|
|
||||||
|
|
||||||
try!(parse_extra_field(&mut result, extra_field.as_slice()));
|
|
||||||
|
|
||||||
// Go back after the central header
|
|
||||||
try!(reader.seek(return_position, io::SeekSet));
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_extra_field(_file: &mut ZipFile, data: &[u8]) -> IoResult<()>
|
|
||||||
{
|
|
||||||
let mut reader = io::BufReader::new(data);
|
|
||||||
while !reader.eof()
|
|
||||||
{
|
|
||||||
let kind = try!(reader.read_le_u16());
|
|
||||||
let len = try!(reader.read_le_u16());
|
|
||||||
debug!("Parsing extra block {:04x}", kind);
|
|
||||||
match kind
|
|
||||||
{
|
|
||||||
_ => try!(reader.seek(len as i64, io::SeekCur)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_local_file_header<T: Writer>(writer: &mut T, file: &ZipFile) -> IoResult<()>
|
|
||||||
{
|
|
||||||
try!(writer.write_le_u32(LOCAL_FILE_HEADER_SIGNATURE));
|
|
||||||
try!(writer.write_le_u16(20));
|
|
||||||
let flag = if !file.file_name.is_ascii() { 1u16 << 11 } else { 0 };
|
|
||||||
try!(writer.write_le_u16(flag));
|
|
||||||
try!(writer.write_le_u16(file.compression_method as u16));
|
|
||||||
try!(writer.write_le_u16(util::tm_to_msdos_time(file.last_modified_time)));
|
|
||||||
try!(writer.write_le_u16(util::tm_to_msdos_date(file.last_modified_time)));
|
|
||||||
try!(writer.write_le_u32(file.crc32));
|
|
||||||
try!(writer.write_le_u32(file.compressed_size as u32));
|
|
||||||
try!(writer.write_le_u32(file.uncompressed_size as u32));
|
|
||||||
try!(writer.write_le_u16(file.file_name.as_bytes().len() as u16));
|
|
||||||
let extra_field = try!(build_extra_field(file));
|
|
||||||
try!(writer.write_le_u16(extra_field.len() as u16));
|
|
||||||
try!(writer.write(file.file_name.as_bytes()));
|
|
||||||
try!(writer.write(extra_field.as_slice()));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_central_directory_header<T: Writer>(writer: &mut T, file: &ZipFile) -> IoResult<()>
|
|
||||||
{
|
|
||||||
try!(writer.write_le_u32(CENTRAL_DIRECTORY_HEADER_SIGNATURE));
|
|
||||||
try!(writer.write_le_u16(0x14FF));
|
|
||||||
try!(writer.write_le_u16(20));
|
|
||||||
let flag = if !file.file_name.is_ascii() { 1u16 << 11 } else { 0 };
|
|
||||||
try!(writer.write_le_u16(flag));
|
|
||||||
try!(writer.write_le_u16(file.compression_method as u16));
|
|
||||||
try!(writer.write_le_u16(util::tm_to_msdos_time(file.last_modified_time)));
|
|
||||||
try!(writer.write_le_u16(util::tm_to_msdos_date(file.last_modified_time)));
|
|
||||||
try!(writer.write_le_u32(file.crc32));
|
|
||||||
try!(writer.write_le_u32(file.compressed_size as u32));
|
|
||||||
try!(writer.write_le_u32(file.uncompressed_size as u32));
|
|
||||||
try!(writer.write_le_u16(file.file_name.as_bytes().len() as u16));
|
|
||||||
let extra_field = try!(build_extra_field(file));
|
|
||||||
try!(writer.write_le_u16(extra_field.len() as u16));
|
|
||||||
try!(writer.write_le_u16(0));
|
|
||||||
try!(writer.write_le_u16(0));
|
|
||||||
try!(writer.write_le_u16(0));
|
|
||||||
try!(writer.write_le_u32(0));
|
|
||||||
try!(writer.write_le_u32(file.header_start as u32));
|
|
||||||
try!(writer.write(file.file_name.as_bytes()));
|
|
||||||
try!(writer.write(extra_field.as_slice()));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_extra_field(_file: &ZipFile) -> IoResult<Vec<u8>>
|
|
||||||
{
|
|
||||||
let writer = io::MemWriter::new();
|
|
||||||
// Future work
|
|
||||||
Ok(writer.unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CentralDirectoryEnd
|
pub struct CentralDirectoryEnd
|
||||||
{
|
{
|
||||||
pub disk_number: u16,
|
pub disk_number: u16,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use compression;
|
use compression;
|
||||||
use types::ZipFile;
|
use types::ZipFile;
|
||||||
use spec;
|
use spec;
|
||||||
|
use writer_spec;
|
||||||
use crc32;
|
use crc32;
|
||||||
use std::default::Default;
|
use std::default::Default;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
@ -120,7 +121,7 @@ impl<W: Writer+Seek> ZipWriter<W>
|
||||||
header_start: header_start,
|
header_start: header_start,
|
||||||
data_start: 0,
|
data_start: 0,
|
||||||
};
|
};
|
||||||
try!(spec::write_local_file_header(writer, &file));
|
try!(writer_spec::write_local_file_header(writer, &file));
|
||||||
|
|
||||||
let header_end = try!(writer.tell());
|
let header_end = try!(writer.tell());
|
||||||
self.stats.start = header_end;
|
self.stats.start = header_end;
|
||||||
|
@ -152,7 +153,7 @@ impl<W: Writer+Seek> ZipWriter<W>
|
||||||
file.compressed_size = try!(writer.tell()) - self.stats.start;
|
file.compressed_size = try!(writer.tell()) - self.stats.start;
|
||||||
|
|
||||||
try!(writer.seek(file.header_start as i64, io::SeekSet));
|
try!(writer.seek(file.header_start as i64, io::SeekSet));
|
||||||
try!(spec::write_local_file_header(writer, file));
|
try!(writer_spec::write_local_file_header(writer, file));
|
||||||
try!(writer.seek(0, io::SeekEnd));
|
try!(writer.seek(0, io::SeekEnd));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -168,7 +169,7 @@ impl<W: Writer+Seek> ZipWriter<W>
|
||||||
let central_start = try!(writer.tell());
|
let central_start = try!(writer.tell());
|
||||||
for file in self.files.iter()
|
for file in self.files.iter()
|
||||||
{
|
{
|
||||||
try!(spec::write_central_directory_header(writer, file));
|
try!(writer_spec::write_central_directory_header(writer, file));
|
||||||
}
|
}
|
||||||
let central_size = try!(writer.tell()) - central_start;
|
let central_size = try!(writer.tell()) - central_start;
|
||||||
|
|
||||||
|
|
60
src/writer_spec.rs
Normal file
60
src/writer_spec.rs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
use std::io;
|
||||||
|
use std::io::IoResult;
|
||||||
|
use types::ZipFile;
|
||||||
|
use spec;
|
||||||
|
use util;
|
||||||
|
|
||||||
|
pub fn write_local_file_header<T: Writer>(writer: &mut T, file: &ZipFile) -> IoResult<()>
|
||||||
|
{
|
||||||
|
try!(writer.write_le_u32(spec::LOCAL_FILE_HEADER_SIGNATURE));
|
||||||
|
try!(writer.write_le_u16(20));
|
||||||
|
let flag = if !file.file_name.is_ascii() { 1u16 << 11 } else { 0 };
|
||||||
|
try!(writer.write_le_u16(flag));
|
||||||
|
try!(writer.write_le_u16(file.compression_method as u16));
|
||||||
|
try!(writer.write_le_u16(util::tm_to_msdos_time(file.last_modified_time)));
|
||||||
|
try!(writer.write_le_u16(util::tm_to_msdos_date(file.last_modified_time)));
|
||||||
|
try!(writer.write_le_u32(file.crc32));
|
||||||
|
try!(writer.write_le_u32(file.compressed_size as u32));
|
||||||
|
try!(writer.write_le_u32(file.uncompressed_size as u32));
|
||||||
|
try!(writer.write_le_u16(file.file_name.as_bytes().len() as u16));
|
||||||
|
let extra_field = try!(build_extra_field(file));
|
||||||
|
try!(writer.write_le_u16(extra_field.len() as u16));
|
||||||
|
try!(writer.write(file.file_name.as_bytes()));
|
||||||
|
try!(writer.write(extra_field.as_slice()));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_central_directory_header<T: Writer>(writer: &mut T, file: &ZipFile) -> IoResult<()>
|
||||||
|
{
|
||||||
|
try!(writer.write_le_u32(spec::CENTRAL_DIRECTORY_HEADER_SIGNATURE));
|
||||||
|
try!(writer.write_le_u16(0x14FF));
|
||||||
|
try!(writer.write_le_u16(20));
|
||||||
|
let flag = if !file.file_name.is_ascii() { 1u16 << 11 } else { 0 };
|
||||||
|
try!(writer.write_le_u16(flag));
|
||||||
|
try!(writer.write_le_u16(file.compression_method as u16));
|
||||||
|
try!(writer.write_le_u16(util::tm_to_msdos_time(file.last_modified_time)));
|
||||||
|
try!(writer.write_le_u16(util::tm_to_msdos_date(file.last_modified_time)));
|
||||||
|
try!(writer.write_le_u32(file.crc32));
|
||||||
|
try!(writer.write_le_u32(file.compressed_size as u32));
|
||||||
|
try!(writer.write_le_u32(file.uncompressed_size as u32));
|
||||||
|
try!(writer.write_le_u16(file.file_name.as_bytes().len() as u16));
|
||||||
|
let extra_field = try!(build_extra_field(file));
|
||||||
|
try!(writer.write_le_u16(extra_field.len() as u16));
|
||||||
|
try!(writer.write_le_u16(0));
|
||||||
|
try!(writer.write_le_u16(0));
|
||||||
|
try!(writer.write_le_u16(0));
|
||||||
|
try!(writer.write_le_u32(0));
|
||||||
|
try!(writer.write_le_u32(file.header_start as u32));
|
||||||
|
try!(writer.write(file.file_name.as_bytes()));
|
||||||
|
try!(writer.write(extra_field.as_slice()));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_extra_field(_file: &ZipFile) -> IoResult<Vec<u8>>
|
||||||
|
{
|
||||||
|
let writer = io::MemWriter::new();
|
||||||
|
// Future work
|
||||||
|
Ok(writer.unwrap())
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue