]> Cypherpunks repositories - dsc.git/blob - dsc
779367c364ab644545acd56316052925fc464f02c2b5090eb93fb394026abf66
[dsc.git] / dsc
1 #!/usr/bin/env jimsh
2 # dsc -- damn small configuration manager
3 # Copyright (C) 2025-2026 Sergey Matveev <stargrave@stargrave.org>
4 #               2025-2026 Vladimir Bobrov
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, version 3 of the License.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 if {$argc == 0} {
19     puts -nonewline stderr {Usage:
20     dsc list [-v] [prefix] -- list all available options
21     dsc add opt       -- add /*/ section
22     dsc del opt       -- delete /*/ section
23     dsc del ""        -- delete the whole configuration, useful for import
24     dsc has opt       -- check if specified /*/ option exists
25     dsc set opt value -- set option's value
26     dsc set opt ""    -- unset value, make it default one
27     dsc set opt <data -- set binary option's value
28     dsc get opt       -- get option's value, maybe default one
29     dsc get opt/*     -- list /*/ sections
30     dsc diff [prefix] -- show the difference between saved and stash
31     dsc revert opt    -- revert opt's configuration
32     dsc commit        -- commit (save) configuration
33     dsc export [prefix] >file.txtar -- export (whole by default) configuration
34     dsc import-check    <file.txtar -- verify configuration's checksum
35     dsc import-pipe     <file.txtar -- import configuration
36     dsc import           file.txtar -- verify and import configuration
37     dsc path opt      -- get full path to option's value (if set)
38     dsc apply prefix  -- apply configuration on given prefix
39     dsc csum <data    -- compute data's checksum
40
41 Environmental variables:
42     $DSC_SCHEMA -- path to the schema definition
43     $DSC_STASH  -- path to stashed/unsaved state
44     $DSC_SAVED  -- path to committed/saved state
45     $DSC_HASHES -- sha2-512:sha512 by default
46                    Comma-separated list of hash-name:cmd pairs
47
48 There are two kinds of options:
49     * array/list ones, which are identified with /*/ in "list"'s
50       output. "add", "del", "get opt/*" commands apply
51     * ordinary scalar ones, which can be "set", "get opt"
52 }
53     exit 1
54 }
55
56 set CopyBufLen [expr {128 * 1024}]
57 if {[info exists env(DSC_SCHEMA)]} {set Schema $env(DSC_SCHEMA)} {set Schema schema}
58 if {[info exists env(DSC_STASH)]} {set Stash $env(DSC_STASH)} {set Stash stash}
59 if {[info exists env(DSC_SAVED)]} {set Saved $env(DSC_SAVED)} {set Saved saved}
60 if {[info exists env(DSC_HASHES)]} {
61     set Hashes [list]
62     foreach pair [split $env(DSC_HASHES) ,] {
63         set cols [split $pair :]
64         if {[llength $cols] != 2} {
65             error "bad DSC_HASHES"
66         }
67         lappend Hashes [lindex $cols 0] [lindex $cols 1]
68     }
69 } {
70     set Hashes [list sha2-512 sha512]
71 }
72
73 proc readents {root typ} {
74     set ents [glob -directory $root -tails -nocomplain -- *]
75     set ents [lmap d $ents {if {[file type $root/$d] == $typ} {set d} {continue}}]
76     return [lsort $ents]
77 }
78
79 proc walk {root typ} {
80     set rv [list]
81     set root [string trimright $root /]
82     foreach s [readents $root $typ] {
83         lappend rv $root/$s
84         lappend rv {*}[walk $root/$s $typ]
85     }
86     return $rv
87 }
88
89 proc fileread {fn} {
90     set fh [open $fn]
91     set v [read $fh]
92     close $fh
93     return $v
94 }
95
96 proc find-opt-schema {opt} {
97     global Schema
98     set pth .
99     set opt [string trimright $opt /]
100     foreach e [split $opt /] {
101         if {[file exists $Schema/$pth/$e]} {
102             set pth $pth/$e
103             continue
104         }
105         if {[file exists $Schema/$pth/*]} {
106             set pth $pth/*
107             continue
108         }
109         puts stderr "can not find $opt in schema"
110         exit 1
111     }
112     return [string range $pth 2 end]
113 }
114
115 proc is-bin {opt} {
116     global Schema
117     return [file exists "$Schema/[find-opt-schema $opt]/bin"]
118 }
119
120 proc run-checker {opt v} {
121     global Schema
122     set fh [open [list "|$Schema/[find-opt-schema $opt]/check" $opt 2>@1 << $v]]
123     set v [$fh read]
124     if {[catch {close $fh}]} {
125         puts -nonewline stderr $v
126         exit 1
127     }
128     catch {untaint v}
129     return $v
130 }
131
132 proc assure-exists {opt} {
133     global Stash
134     if {![file exists $Stash/$opt]} {
135         puts stderr "not found"
136         exit 1
137     }
138 }
139
140 proc txtar-fn {line} {
141     if {
142         [string length $line] > 6 &&
143         [string range $line 0 2] == "-- " &&
144         [string range $line end-2 end] == " --"
145     } {
146         return [string range $line 3 end-3]
147     }
148     return ""
149 }
150
151 proc path-sanitize {fn} {
152     if {[string index $fn 0] == "/"} {
153         puts stderr "absolute paths are forbidden"
154         exit 1
155     }
156     foreach e [file split $fn] {
157         if {$e == ".."} {
158             puts stderr "relative paths are forbidden"
159             exit 1
160         }
161     }
162 }
163
164 proc assure-all-list-params-exist {opt {offset 0}} {
165     global Stash
166     set pth [find-opt-schema $opt]
167     set idx [lindex [lsearch -all [file split $pth] "*"] end-$offset]
168     if {$idx != ""} {
169         set pth [file join {*}[lrange [file split $opt] 0 $idx]]
170         if {![file exists $Stash/$pth]} {
171             puts stderr "$pth does not exist"
172             exit 1
173         }
174     }
175 }
176
177 set opt [lindex $argv 1]
178 switch [lindex $argv 0] {
179     list {
180         set verbose no
181         set prefix [lindex $argv 1]
182         if {$argc > 1 && [lindex $argv 1] == "-v"} {
183             set verbose yes
184             set prefix [lindex $argv 2]
185         }
186         foreach opt [walk $Schema directory] {
187             if {![file exists $opt/title]} {continue}
188             set name [string range $opt [expr {[string length $Schema] + 1}] end]
189             if {$prefix != ""} {
190                 if {[string range $name 0 [string length $prefix]-1] != $prefix} {
191                     continue
192                 }
193             }
194             set v [fileread $opt/title]
195             puts -nonewline "$name\t$v"
196             if {$verbose} {
197                 if {[file exists $opt/descr]} {
198                     set lines [split [fileread $opt/descr] "\n"]
199                     set lines [lrange $lines 0 end-1]
200                     foreach line $lines {
201                         puts "\t$line"
202                     }
203                 }
204             }
205         }
206     }
207     add {
208         assure-all-list-params-exist $opt 1
209         set dir [file dirname $opt]
210         set tail [run-checker $opt [file tail $opt]]
211         set tail [string trimright $tail "\n"]
212         file mkdir $Stash/$dir/$tail
213         puts $dir/$tail
214     }
215     del {
216         if {$opt != ""} {
217             assure-exists $opt
218         }
219         file delete -force $Stash/$opt
220     }
221     set {
222         assure-all-list-params-exist $opt
223         if {[llength $argv] > 2} {
224             set v [lindex $argv 2]
225             if {$v == ""} {
226                 file delete $Stash/$opt
227                 exit
228             }
229         }
230         file mkdir "$Stash/[file dirname $opt]"
231         if {[is-bin $opt]} {
232             set fh [open $Stash/$opt w]
233             fconfigure $fh -translation binary
234             fconfigure stdin -translation binary
235             stdin copyto $fh
236             close $fh
237         } else {
238             set v [run-checker $opt $v]
239             set fh [open $Stash/$opt w]
240             puts -nonewline $fh $v
241             close $fh
242         }
243     }
244     has {
245         assure-exists $opt
246     }
247     find-opt-schema {
248         puts [find-opt-schema $opt]
249     }
250     get {
251         if {[file tail $opt] == "*"} {
252             set opt [file dirname $opt]
253             if {![file exists $Stash/$opt]} {
254                 exit
255             }
256             foreach dir [readents $Stash/$opt directory] {
257                 puts $dir
258             }
259             exit
260         }
261         if {[file exists $Stash/$opt]} {
262             if {[is-bin $opt]} {
263                 set fh [open $Stash/$opt]
264                 fconfigure $fh -translation binary
265                 fconfigure stdout -translation binary
266                 $fh copyto stdout
267                 close $fh
268             } else {
269                 puts -nonewline [fileread $Stash/$opt]
270             }
271             exit
272         }
273         puts -nonewline [run-checker $opt ""]
274     }
275     apply {
276         set pth [find-opt-schema $opt]
277         if {[file exists $Schema/$pth/apply]} {
278             puts stderr "applying $opt..."
279             if {[catch {exec {*}[list "$Schema/$pth/apply" $opt] >@stdout 2>@stderr}]} {
280                 exit 1
281             }
282             exit
283         }
284         if {[file exists $Schema/$pth/*]} {
285             set rc 0
286             foreach dir [readents $Stash/$opt directory] {
287                 if {[catch {exec {*}[list dsc apply $opt/$dir] >@stdout 2>@stderr}]} {
288                     set rc 1
289                 }
290             }
291             exit $rc
292         }
293         foreach ent [readents $Schema/$opt directory] {
294             if {[catch {exec {*}[list dsc apply $opt/$ent] >@stdout 2>@stderr}]} {
295                 set rc 1
296             }
297         }
298     }
299     diff {
300         set dirsSaved [file tempfile dirsSaved.XXXXXX]
301         set fh [open $dirsSaved w]
302         foreach fn [walk $Saved/$opt directory] {
303             puts $fh [string range $fn [string length $Saved]+1 end]
304         }
305         close $fh
306         set dirsStash [file tempfile dirsStash.XXXXXX]
307         set fh [open $dirsStash w]
308         foreach fn [walk $Stash/$opt directory] {
309             puts $fh [string range $fn [string length $Stash]+1 end]
310         }
311         close $fh
312         set fh [open [list |diff -u -L dirs -L dirs $dirsSaved $dirsStash]]
313         puts -nonewline [read $fh]
314         catch {close $fh}
315         file delete $dirsSaved
316         file delete $dirsStash
317         set fh [open [list |diff -urN $Saved/$opt $Stash/$opt]]
318         set prefixSaved "--- $Saved/"
319         set prefixSavedLen [string length $prefixSaved]
320         set prefixStash "+++ $Stash/"
321         set prefixStashLen [string length $prefixStash]
322         while {[gets $fh line] >= 0} {
323             if {[string range $line 0 3] == "diff"} {
324                 continue
325             }
326             if {[string range $line 0 $prefixSavedLen-1] == $prefixSaved} {
327                 puts "--- [string range $line $prefixSavedLen end]"
328                 continue
329             }
330             if {[string range $line 0 $prefixStashLen-1] == $prefixStash} {
331                 puts "+++ [string range $line $prefixStashLen end]"
332                 continue
333             }
334             puts $line
335         }
336         catch {close $fh}
337     }
338     revert {
339         catch {file delete -force $Stash/$opt}
340         catch {exec {*}[list cp -a $Saved/$opt $Stash/$opt]}
341     }
342     commit {
343         file delete -force $Saved.bak
344         set tmp $Saved.[expr {int(rand() * 1000000)}]
345         exec {*}[list cp -a $Stash $tmp]
346         exec sync
347         file rename $Saved $Saved.bak
348         file rename $tmp $Saved
349         file delete -force $Saved.bak
350     }
351     csum {
352         set hsh [list]
353         foreach {name cmd} $Hashes {
354             lassign [pipe] r w
355             set fh [open [list |$cmd >@$w] w]
356             fconfigure $fh -translation binary
357             lappend hsh $name $fh $r $w
358         }
359         fconfigure stdin -translation binary
360         while {![eof stdin]} {
361             set buf [read stdin $CopyBufLen]
362             foreach {_ $fh _ _} $hsh {
363                 puts -nonewline $fh $buf
364             }
365         }
366         foreach {name fh r w} $hsh {
367             close $fh
368             close $w
369             gets $r v
370             set v [split $v " "]
371             puts "$name [lindex $v 0]"
372         }
373     }
374     export-raw {
375         set dirs [walk $Saved/$opt directory]
376         if {$opt != ""} {
377             set dirs [list $Saved/$opt {*}$dirs]
378         }
379         puts "-- .dirs --"
380         foreach fn $dirs {
381             puts [string range $fn [string length $Saved]+1 end]
382         }
383         foreach dir $dirs {
384             foreach fn [walk $dir file] {
385                 set sfn [string range $fn [string length $Saved]+1 end]
386                 if {[is-bin $sfn]} {
387                     puts "-- $sfn:base64 --"
388                     set fh [open "|base64 $fn"]
389                     $fh copyto stdout
390                     close $fh
391                 } else {
392                     puts "-- $sfn --"
393                     set fh [open $fn]
394                     while {[gets $fh line] >= 0} {
395                         if {[txtar-fn $line] != ""} {
396                             set line "-- $line --"
397                         }
398                         puts $line
399                     }
400                     close $fh
401                 }
402             }
403         }
404     }
405     export {
406         set exporter [open [list |$argv0 export-raw $opt]]
407         fconfigure $exporter -translation binary
408         lassign [pipe] r w
409         set hasher [open [list |$argv0 csum >@$w] w]
410         fconfigure $hasher -translation binary
411         fconfigure stdout -translation binary
412         while {![eof $exporter]} {
413             set buf [read $exporter $CopyBufLen]
414             puts -nonewline stdout $buf
415             puts -nonewline $hasher $buf
416         }
417         close $hasher
418         close $w
419         puts "-- .csum --"
420         puts -nonewline [read $r]
421     }
422     import-pipe {
423         fconfigure stdin -translation binary
424         while {[gets stdin line] >= 0} {
425             set fn [txtar-fn $line]
426             if {$fn != ""} {
427                 break
428             }
429         }
430         if {$fn == ".dirs"} {
431             while {[gets stdin line] >= 0} {
432                 set fn [txtar-fn $line]
433                 if {$fn == ""} {
434                     path-sanitize $line
435                     file mkdir $Stash/$line
436                 } else {
437                     break
438                 }
439             }
440         }
441         proc openfh {fn} {
442             path-sanitize $fn
443             set bin no
444             if {[string range $fn [expr {[string length $fn]-7}] end] == ":base64"} {
445                 set bin yes
446                 set fn [string range $fn 0 [expr {[string length $fn]-7-1}]]
447             }
448             global Stash
449             file mkdir [file dirname $Stash/$fn]
450             if {$bin} {
451                 set fh [open [list |base64 -d > $Stash/$fn] w]
452             } else {
453                 set fh [open $Stash/$fn w]
454                 fconfigure $fh -translation binary
455             }
456             return $fh
457         }
458         set fh [openfh $fn]
459         while {[gets stdin line] >= 0} {
460             set fn [txtar-fn $line]
461             if {$fn == ""} {
462                 puts $fh $line
463             } elseif {[string range $fn 0 2] == "-- "} {
464                 puts $fh $fn
465             } elseif {$fn == ".csum"} {
466                 break
467             } else {
468                 close $fh
469                 set fh [openfh $fn]
470             }
471         }
472         close $fh
473         exec sync
474     }
475     import-check {
476         lassign [pipe] r w
477         set hasher [open [list |$argv0 csum >@$w] w]
478         fconfigure $hasher -translation binary
479         fconfigure stdin -translation binary
480         while {[gets stdin line] >= 0} {
481             if {$line == "-- .csum --"} {
482                 break
483             }
484             puts $hasher $line
485         }
486         close $hasher
487         close $w
488         set got [read $r]
489         set exp [read stdin]
490         if {$got == $exp} {
491             exit 0
492         }
493         puts stderr "integrity failure"
494         puts -nonewline stderr $got
495         puts stderr "\t!="
496         puts -nonewline stderr $exp
497         exit 1
498     }
499     import {
500         exec $argv0 import-check <$opt
501         exec $argv0 import-pipe <$opt
502     }
503     path {
504         if {[file exists $Stash/$opt]} {
505             puts [file normalize $Stash/$opt]
506         }
507     }
508     default {
509         puts stderr "unknown cmd"
510         exit 1
511     }
512 }
513
514 # vim: ft=tcl