#!/bin/env tclsh
package require sqlite3
package require Thread
package require tcl::chan::string
set init {
package require sha256
proc readbak {path} {
set fd [open $path rb]
set magic [read $fd 6]
if {$magic != "AMEBAK"} {
close $fd
return -code error "invalid magic string: \"$magic\""
}
set ver [read $fd 1]
binary scan $ver c ver
if {$ver != 0} {
close $fd
return -code error "archive file too new: $ver (self: 0)"
}
set mode [read $fd 1]
read $fd 8; # skip filesize
switch -- $mode {
r {}
F {
zlib push inflate $fd -level 9
}
default {
close $fd
return -code error "unknown compression mode: $mode"
}
}
set ret [read $fd]
close $fd
return $ret
}
proc readstore {store hash {hex 0}} {
set hexhash $hash
if {!$hex} { set hexhash [binary encode hex $hash] }
set path $store
append path "/[string range $hexhash 0 1]"
append path "/[string range $hexhash 2 3]"
append path "/[string range $hexhash 4 5]/"
append path "/[string range $hexhash 6 end]"
if {[file exists $path]} {
return [readbak $path]
} else {
return -code error "missing file"
}
}
proc writebak {path data} {
binary scan $data c _
set bench [string range $data 0 10000000]
binary scan $bench c _
set Fbench [zlib deflate $bench 9]
set fd [open $path wb]
puts -nonewline $fd "AMEBAK\0"
set compress 0
if {[string length $Fbench] < ([string length $bench] * 95 / 100)} {
puts -nonewline $fd "F"
set compress 1
} else { puts -nonewline $fd "r" }
puts -nonewline $fd [binary format W [string length $data]]
if {$compress} { zlib push deflate $fd -level 9 }
puts -nonewline $fd $data
close $fd
}
proc writestore {store data {hex 0}} {
set hash [::sha2::sha256 -bin -- $data]
set hexhash [binary encode hex $hash]
set path $store
append path "/[string range $hexhash 0 1]"
append path "/[string range $hexhash 2 3]"
append path "/[string range $hexhash 4 5]/"
file mkdir $path
set path "$path/[string range $hexhash 6 end]"
if {![file exists $path]} {
writebak $path $data
}
if {$hex} {
return $hexhash
} else {
return $hash
}
}
proc statbak {path} {
set fd [open $path rb]
set magic [read $fd 6]
if {$magic != "AMEBAK"} {
close $fd
return -code error "invalid magic string: \"$magic\""
}
set ver [read $fd 1]
binary scan $ver c ver
if {$ver != 0} {
close $fd
return -code error "archive file too new: $ver (self: 0)"
}
set mode [read $fd 1]
switch -- $mode {
r { set mode raw }
F { set mode deflate }
default {
close $fd
return -code error "unknown compression mode: $mode"
}
}
set disksize [file size $path]
binary scan [read $fd 8] W datasize
close $fd
return [list ver $ver mode $mode disksize $disksize datasize $datasize]
}
proc statstore {store hash {hex 0}} {
set hexhash $hash
if {!$hex} { set hexhash [binary encode hex $hash] }
set path $store
append path "/[string range $hexhash 0 1]"
append path "/[string range $hexhash 2 3]"
append path "/[string range $hexhash 4 5]/"
append path "/[string range $hexhash 6 end]"
if {![file exists $path]} return
dict replace [statbak $path] path $path
}
}
eval $init
namespace eval ::tar {}
proc ::tar::nextrec {fd} {
set hdr [read $fd 512]
binary scan $hdr c _
if {[string length $hdr] != 512} { die "got partial header: [string length $hdr] b" }
binary scan $hdr a100c24a11c1c12c8a1c* name blind1 size _ blind2 _ type blind3
set name [lindex [split $name "\0"] 0]
if {$name == {}} return
set data [read $fd [expr {$size}]]
if {$size % 512 != 0} {
read $fd [expr {512 - ($size % 512)}]
}
return [list name $name type $type data [binary encode hex $data] blind1 $blind1 blind2 $blind2 blind3 $blind3]
}
proc ::tar::fmtrec {rec} {
set name [dict get $rec name]
set type [dict get $rec type]
set size [expr {[string length [dict get $rec data]] / 2}]
set size [format %011o $size]
set blind1 [dict get $rec blind1]
set blind2 [dict get $rec blind2]
set blind3 [dict get $rec blind3]
set hdr [binary format a100c24a12c12A8a1c* $name $blind1 $size $blind2 "" $type $blind3]
binary scan $hdr c* bytes
set checksum 0
foreach byte $bytes {
incr checksum [expr {$byte & 0xFF}]
}
set checksum [format %07o $checksum]
set hdr [binary format a100c24a12c12a8a1c* $name $blind1 $size $blind2 $checksum $type $blind3]
append hdr [binary decode hex [dict get $rec data]]
if {$size % 512 != 0} {
append hdr [binary format a[expr {512 - ($size % 512)}] ""]
}
return $hdr
}
proc usage {} {
global argv0
puts stderr {usage: $argv0 command store ?args ...?
commands:
init
- initialize a store in store
importlist name ?listfile?
- imports the contents of listfile under name, treating each line as a filepath
- if listfile is ommitted, reads stdin
importtar name ?tarfile?
- imports the contents of tarfile under name. will not auto-decompress.
- if tarfile is ommitted, reads stdin
import ?filepath?
- stores the contents of filepath, returning the hash
- if filepath is ommitted, reads stdin
export id
- exports the contents of backup id to a file named like NAME_DATE
print hash
- prints the contents of the file named by hash on stdout
stat hash
- prints metadata about the file named by hash}
exit -1
}
proc die {reason} {
global argv0
puts stderr "$argv0: fatal: $reason"
exit -2
}
if {[llength $argv] < 2} usage
set argv [lassign $argv command store]
set dbpath "$store/index.db"
set arpath "$store/archive"
if {$command == "init"} {
if {[file exists $dbpath]} { die "won't overwrite \"$dbpath\"." }
file mkdir $store
file mkdir $arpath
sqlite3 db $dbpath
db eval {
CREATE TABLE tarballs (name TEXT NOT NULL, type TEXT NOT NULL, ref TEXT NOT NULL, desc TEXT NOT NULL, date TEXT NOT NULL);
}
exit
}
if {[catch {sqlite3 db $dbpath -create false}]} { die "can't find a store at \"$store\"." }
set fd [open /proc/cpuinfo r]
set nproc [llength [regexp -all -lineanchor -inline "^processor" [read $fd]]]
close $fd
set pool [tpool::create -maxworkers $nproc -initcmd [concat $init "; " [list set arpath $arpath]]]
switch -- $command {
importlist {
if {[llength $argv] ni {1 2}} usage
set fname [lindex $argv 0]
if {[llength $argv] == 2} {
set fd [open [lindex $argv 1]]
} else {
set fd stdin
}
set files [split [string trim [read $fd]] "\n"]
set joblist {}
set filelist {}
if {$fd != "stdin"} { close $fd }
foreach filename $files {
if {![file exists $filename]} {
puts stderr "warning: \"$filename\" doesn't exist"
continue
}
set script [concat [list set filename $filename] "; " {
set fd [open $filename rb]
set data [read $fd]
close $fd
set hash [writestore $arpath $data 1]
return [list [string trimleft [file normalize $filename] "/"] $hash]
}]
lappend joblist [tpool::post $pool $script]
}
foreach job $joblist {
tpool::wait $pool $job
append filelist " [tpool::get $pool $job]"
}
set filelist [lsort -stride 2 $filelist]
set ref [writestore $arpath $filelist 1]
set date [clock seconds]
set rowid [db eval {
INSERT INTO tarballs VALUES ($fname, "list", $ref, "", $date) RETURNING rowid;
}]
puts stderr "inserted as archive $rowid with hash $ref"
}
importtar {
if {[llength $argv] ni {1 2}} usage
set fname [lindex $argv 0]
if {[llength $argv] == 2} {
set fd [open [lindex $argv 1]] rb
} else {
set fd stdin
fconfigure stdin -translation binary
}
set joblist {}
set result {}
while {[set record [::tar::nextrec $fd]] != {}} {
set script [concat [list set record $record] "; " {
set hash [writestore $arpath [binary decode hex [dict get $record data]] 1]
dict set record data $hash
return $record
}]
lappend joblist [tpool::post $pool $script]
}
if {$fd != "stdin"} { close $fd }
foreach job $joblist {
tpool::wait $pool $job
append result [::tar::fmtrec [tpool::get $pool $job]]
}
append result [binary format a[expr {16384 - ([string length $result] % 16384)}] ""]
set ref [writestore $arpath $result 1]
set date [clock seconds]
set rowid [db eval {
INSERT INTO tarballs VALUES ($fname, "tar", $ref, "", $date) RETURNING rowid;
}]
puts stderr "inserted as archive $rowid with hash $ref"
}
export {
if {[llength $argv] != 1} usage
set rowid [lindex $argv 0]
set record [db eval {
SELECT ref,type,name,date FROM tarballs WHERE rowid = $rowid;
}]
if {[llength $record] == 0} { die "unknown archive id" }
lassign $record ref type name date
set exportname [regsub "/" "${name}_[clock format $date -format {%Y-%m-%d_%H.%M.%S}]" "-"]
set archive [readstore $arpath $ref 1]
switch -- $type {
list {
file mkdir $exportname
set joblist {}
foreach {path hash} $archive {
set script [concat [list set path "$exportname/$path"] "; " [list set hash $hash] "; " {
puts "$hash -> $path"
file mkdir [file dirname $path]
set fd [open "$path" wb]
puts -nonewline $fd [readstore $arpath $hash 1]
close $fd
}]
lappend joblist [tpool::post $pool $script]
}
foreach job $joblist {
tpool::wait $pool $job
}
}
tar {
set exportname "$exportname.tar"
set exportfd [open $exportname wb]
set importfd [::tcl::chan::string $archive]
fconfigure $importfd -translation binary
set joblist {}
while {[set record [::tar::nextrec $importfd]] != {}} {
set script [concat [list set record $record] "; " {
dict set record data [binary encode hex [readstore $arpath [dict get $record data] 1]]
return $record
}]
lappend joblist [tpool::post -nowait $pool $script]
}
foreach job $joblist {
tpool::wait $pool $job
puts -nonewline $exportfd [::tar::fmtrec [tpool::get $pool $job]]
}
puts -nonewline $exportfd [binary format a[expr {16384 - ([tell $exportfd] % 16384)}] ""]
close $exportfd
}
default {
die "database corrupt: unknown archive type \"$type\""
}
}
puts "exported to $exportname"
}
print {
if {[llength $argv] != 1} usage
set stat [statstore $arpath [lindex $argv 0] 1]
if {$stat == {}} { die "unknown hash" }
puts [readstore $arpath [lindex $argv 0] 1]
}
stat {
if {[llength $argv] != 1} usage
set stat [statstore $arpath [lindex $argv 0] 1]
if {$stat == {}} { die "unknown hash" }
puts "file [lindex $argv 0]:"
puts " version: [dict get $stat ver]"
puts " compression: [dict get $stat mode]"
if {[dict get $stat mode] != "raw"} {
puts " compressed size (bytes): [dict get $stat disksize]"
}
puts " data size (bytes): [dict get $stat datasize]"
}
import {
if {[llength $argv] > 1} usage
if {[llength $argv] == 1} {
set fd [open [lindex $argv 0]]
} else {
set fd stdin
}
set data [read $fd]
set hash [writestore $arpath $data 1]
puts "imported as $hash"
}
default usage
}