@ -19,6 +19,7 @@ import (
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
@ -188,6 +189,15 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
dir := t . TempDir ( )
in := filepath . Join ( dir , "secret.txt" )
os . WriteFile ( in , [ ] byte ( "round trip through the CLI" ) , 0 o600 )
fotos := filepath . Join ( dir , "fotos" )
os . MkdirAll ( filepath . Join ( fotos , "sub" ) , 0 o700 )
os . MkdirAll ( filepath . Join ( fotos , "__MACOSX" ) , 0 o700 )
os . WriteFile ( filepath . Join ( fotos , "a.jpg" ) , [ ] byte ( "jpeg" ) , 0 o600 )
os . WriteFile ( filepath . Join ( fotos , "sub" , "b.txt" ) , [ ] byte ( "b" ) , 0 o600 )
os . WriteFile ( filepath . Join ( fotos , ".DS_Store" ) , [ ] byte ( "x" ) , 0 o600 )
os . WriteFile ( filepath . Join ( fotos , "__MACOSX" , "._a.jpg" ) , [ ] byte ( "x" ) , 0 o600 )
old := time . Date ( 2020 , 1 , 2 , 3 , 4 , 5 , 0 , time . UTC )
os . Chtimes ( in , old , old )
p := profile . Quicknet ( )
unlock := time . Unix ( p . GenesisTime + 999 * 3 , 0 ) . UTC ( ) // round 1000
genesis := time . Unix ( p . GenesisTime , 0 )
@ -196,12 +206,13 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
os . WriteFile ( key , [ ] byte ( x . String ( ) + "\n" ) , 0 o600 )
dkc , dkk := filepath . Join ( dir , "s.dkc" ) , filepath . Join ( dir , "s.dkk" )
_ , stderr , err := cli ( t , genesis , "encrypt" , "-at" , unlock . Format ( time . RFC3339 ) , "-in" , in , "- out", dkc ,
"- policy", "time_and_key" , "-recipient" , x . Recipient ( ) . String ( ) , "-dkk" , dkk )
_ , stderr , err := cli ( t , genesis , "encrypt" , "-at" , unlock . Format ( time . RFC3339 ) , "-in" , in , "- in", fotos , "- out", dkc ,
"- comment", "Hola\tmundo" , "-author" , "Ana" , "- policy", "time_and_key" , "-recipient" , x . Recipient ( ) . String ( ) , "-dkk" , dkk )
if err != nil {
t . Fatalf ( "%v\n%s" , err , stderr )
}
if ! strings . Contains ( stderr , "round 1000" ) || ! strings . Contains ( stderr , "format 2: 26 bytes of content, padded to 256 (reforzado)" ) ||
if ! strings . Contains ( stderr , "round 1000" ) || ! strings . Contains ( stderr , "format 3: 3 files, " ) ||
! strings . Contains ( stderr , ".DS_Store, which the system creates on its own" ) || ! strings . Contains ( stderr , "__MACOSX, which the system" ) ||
strings . Contains ( stderr , "not post-quantum" ) {
t . Fatalf ( "unexpected report:\n%s" , stderr )
}
@ -210,7 +221,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
}
// Spec §29.1: the padding rule is one of the two, never none.
small := filepath . Join ( dir , "small.dkc" )
if _ , stderr , err := cli ( t , genesis , "encrypt" , "-at" , unlock . Format ( time . RFC3339 ) , "-in" , in , "-out" , small , "-padding" , "bloque256" ); err != nil ||
if _ , stderr , err := cli ( t , genesis , "encrypt" , "-at" , unlock . Format ( time . RFC3339 ) , "-in" , in , "-out" , small , "-padding" , "bloque256" , "-no-mtime" ); err != nil ||
! strings . Contains ( stderr , "(bloque256)" ) {
t . Fatalf ( "-padding bloque256: %v\n%s" , err , stderr )
}
@ -223,18 +234,158 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
t . Fatal ( err )
}
var v inspectview . View
if err := json . Unmarshal ( [ ] byte ( stdout ) , & v ) ; err != nil || ! v . Valid || v . Round != 1000 || v . AccessPolicy != "time_and_key" || v . Format != 2 {
if err := json . Unmarshal ( [ ] byte ( stdout ) , & v ) ; err != nil || ! v . Valid || v . Round != 1000 || v . AccessPolicy != "time_and_key" || v . Format != 3 {
t . Fatalf ( "inspect: %+v %v" , v , err )
}
for i , extra := range [ ] [ ] string { { "-dkk" , dkk } , { "-identity" , key } } {
out := filepath . Join ( dir , fmt . Sprintf ( "out%d" , i ) )
args := append ( [ ] string { "decrypt" , "-in" , dkc , "-out" , out , "-relay" , relay ( t ) } , extra ... )
if _ , stderr , err := cli ( t , later , args ... ) ; err != nil {
stdout , stderr , err := cli ( t , later , args ... )
if err != nil {
t . Fatalf ( "%v\n%s" , err , stderr )
}
if b , _ := os . ReadFile ( out ) ; string ( b ) != "round trip through the CLI" {
t . Fatal ( "plaintext differs" )
for name , want := range map [ string ] string { "secret.txt" : "round trip through the CLI" , "fotos/a.jpg" : "jpeg" , "fotos/sub/b.txt" : "b" } {
if b , _ := os . ReadFile ( filepath . Join ( out , filepath . FromSlash ( name ) ) ) ; string ( b ) != want {
t . Errorf ( "%s: %q" , name , b )
}
}
if entries , _ := os . ReadDir ( out ) ; len ( entries ) != 2 {
t . Errorf ( "%d entries in the folder, want fotos and secret.txt" , len ( entries ) )
}
if info , err := os . Stat ( filepath . Join ( out , "secret.txt" ) ) ; err != nil || ! info . ModTime ( ) . Equal ( old ) {
t . Errorf ( "mtime of secret.txt: %v" , err )
}
// Spec §29.7: the verdicts, the declared author and the comment, as
// text of the creator, the paths, and the verdicts again.
want := "Sin firma de autor.\n" + authorLabel + "\n│ Ana\n┌ " + commentTitle + "\n│ Hola mundo\n└\n" +
"Ficheros escritos en " + out + " (3):\n│ fotos/a.jpg\n│ fotos/sub/b.txt\n│ secret.txt\nSin firma de autor.\n"
if stdout != want {
t . Errorf ( "presentation:\n%s\nwant:\n%s" , stdout , want )
}
}
// The folder exists: nothing is requested and nothing changes.
out := filepath . Join ( dir , "out0" )
if _ , _ , err := cli ( t , later , "decrypt" , "-in" , dkc , "-out" , out , "-dkk" , dkk , "-relay" , "http://127.0.0.1:1" ) ; err == nil || ! strings . Contains ( err . Error ( ) , "already exists" ) {
t . Fatalf ( "decrypted into an existing folder: %v" , err )
}
// -no-mtime: the file gets the time of its extraction.
out = filepath . Join ( dir , "small" )
if _ , stderr , err := cli ( t , later , "decrypt" , "-in" , small , "-out" , out , "-relay" , relay ( t ) ) ; err != nil {
t . Fatalf ( "%v\n%s" , err , stderr )
}
if info , err := os . Stat ( filepath . Join ( out , "secret.txt" ) ) ; err != nil || info . ModTime ( ) . Equal ( old ) {
t . Errorf ( "-no-mtime kept the mtime: %v" , err )
}
}
// Spec §56: a format 3 capsule that fails leaves no folder, and one without
// files creates none.
func TestDecryptFormat3LeavesNothing ( t * testing . T ) {
dir := t . TempDir ( )
p := profile . Quicknet ( )
unlock := time . Unix ( p . GenesisTime + 999 * 3 , 0 ) . UTC ( )
genesis := time . Unix ( p . GenesisTime , 0 )
in := filepath . Join ( dir , "a.txt" )
os . WriteFile ( in , [ ] byte ( "a" ) , 0 o600 )
dkc , note := filepath . Join ( dir , "a.dkc" ) , filepath . Join ( dir , "note.dkc" )
if _ , stderr , err := cli ( t , genesis , "encrypt" , "-at" , unlock . Format ( time . RFC3339 ) , "-in" , in , "-out" , dkc ) ; err != nil {
t . Fatalf ( "%v\n%s" , err , stderr )
}
if _ , stderr , err := cli ( t , genesis , "encrypt" , "-at" , unlock . Format ( time . RFC3339 ) , "-comment" , "Solo un comentario" , "-out" , note ) ; err != nil {
t . Fatalf ( "%v\n%s" , err , stderr )
}
b , _ := os . ReadFile ( dkc )
b [ len ( b ) - 1 ] ^ = 1
bad := filepath . Join ( dir , "bad.dkc" )
os . WriteFile ( bad , b , 0 o600 )
out := filepath . Join ( dir , "out" )
if _ , _ , err := cli ( t , later , "decrypt" , "-in" , bad , "-out" , out , "-relay" , relay ( t ) ) ; ! errors . Is ( err , datekeys . ErrIntegrity ) {
t . Fatalf ( "got %v" , err )
}
if _ , err := os . Lstat ( out ) ; ! errors . Is ( err , os . ErrNotExist ) {
t . Fatalf ( "the folder of a failed capsule remains: %v" , err )
}
stdout , stderr , err := cli ( t , later , "decrypt" , "-in" , note , "-out" , out , "-relay" , relay ( t ) )
if err != nil || ! strings . Contains ( stdout , "│ Solo un comentario" ) || ! strings . Contains ( stderr , "no files" ) {
t . Fatalf ( "%v\n%s\n%s" , err , stdout , stderr )
}
if _ , err := os . Lstat ( out ) ; ! errors . Is ( err , os . ErrNotExist ) {
t . Fatalf ( "a capsule without files created its folder: %v" , err )
}
}
// Spec §62.1 rule 15: a path that breaks a rule is refused with the rule and
// the character, and folders are walked without following links.
func TestEncryptRefusesPaths ( t * testing . T ) {
dir := t . TempDir ( )
p := profile . Quicknet ( )
at := time . Unix ( p . GenesisTime + 999 * 3 , 0 ) . UTC ( ) . Format ( time . RFC3339 )
genesis := time . Unix ( p . GenesisTime , 0 )
bidi := filepath . Join ( dir , "a\u202eb.txt" )
if err := os . WriteFile ( bidi , [ ] byte ( "x" ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
_ , _ , err := cli ( t , genesis , "encrypt" , "-at" , at , "-in" , bidi , "-out" , filepath . Join ( dir , "1.dkc" ) )
if err == nil || ! strings . Contains ( err . Error ( ) , "R4: segment 1: invisible U+202E" ) {
t . Fatalf ( "got %v" , err )
}
linked := filepath . Join ( dir , "linked" )
os . Mkdir ( linked , 0 o700 )
if err := os . Symlink ( bidi , filepath . Join ( linked , "link" ) ) ; err != nil {
t . Skipf ( "no symbolic links here: %v" , err )
}
if _ , _ , err := cli ( t , genesis , "encrypt" , "-at" , at , "-in" , linked , "-out" , filepath . Join ( dir , "2.dkc" ) ) ; err == nil || ! strings . Contains ( err . Error ( ) , "is not a regular file" ) {
t . Fatalf ( "got %v" , err )
}
}
// Spec §29.7: every line of the creator goes in pieces of at most W - 3
// columns behind the prefix, counting 2 for any code point that is not
// printable ASCII; TABs of the comment go to the next multiple of 8; the
// verdicts come first and last; risky names get a warning.
func TestPresent ( t * testing . T ) {
o := & capsule . Opened {
Verdicts : capsule . Verdicts { Signature : capsule . VerdictUnreadable , Seal : capsule . VerdictUnreadable } ,
Head : & capsule . Head {
Author : strings . Repeat ( "ñ" , 12 ) ,
Comment : "a\tb\n\n" + strings . Repeat ( "x" , 40 ) ,
Files : [ ] capsule . File { { Path : "Informe.LNK" } , { Path : ".GIT/config" } , { Path : "-rf" } , { Path : "setup.Exe" } , { Path : "fotos/Desktop.ini" } , { Path : "nota.txt" } } ,
} ,
}
var b bytes . Buffer
present ( & b , o , "DIR" , 20 )
lines := strings . Split ( strings . TrimSuffix ( b . String ( ) , "\n" ) , "\n" )
x := capsule . VerdictUnreadable . Text ( )
if lines [ 0 ] != x || lines [ len ( lines ) - 1 ] != x {
t . Errorf ( "the verdict is not first and last:\n%s" , b . String ( ) )
}
for _ , l := range lines {
if rest , ok := strings . CutPrefix ( l , prefix ) ; ok {
w := 0
for _ , r := range rest {
w += runeWidth ( r )
}
if w > 20 - prefixWidth || rest == "" && l != prefix {
t . Errorf ( "piece %q of %d columns" , rest , w )
}
}
}
for _ , want := range [ ] string {
"│ ññññññññ\n│ ññññ\n" , // 12 × 2 columns in pieces of 16
"│ a b\n│ \n│ xxxxxxxxxxxxxxxxx\n" , // the TAB to column 8, an empty line
"│ Informe.LNK\n aviso: es un acceso directo de Windows" ,
"│ .GIT/config\n aviso: está dentro de una carpeta .git" ,
"│ -rf\n aviso: un nombre que empieza por '-'" ,
"│ setup.Exe\n aviso: es un programa o un script" ,
"│ fotos/Desktop.ini\n aviso: es la configuración de una carpeta de Windows" ,
"│ nota.txt\n" + x ,
} {
if ! strings . Contains ( b . String ( ) , want ) {
t . Errorf ( "missing %q in:\n%s" , want , b . String ( ) )
}
}
if pieces ( "" , 17 ) [ 0 ] != "" || len ( pieces ( "ab" , 1 ) ) != 2 {
t . Error ( "an empty line is one piece, and every piece holds a code point" )
}
}